Java并发编程的讨论

来源:互联网 发布:网络教育 心理学 编辑:程序博客网 时间:2024/05/17 22:09

Java并发编程的讨论

NO1.进程及线程的简介

    进程:创建进程相当于在整个系统资源中开辟构建出来一个新的实体,系统会为这个进程分配资源,加载代码,构建运行环境和结构。这个实体就是一个程序,这个程序拥有自己的任务、目的和要实现的事件、功能。但是他却不具备执行者,也就是说他就像一个机构架构,如果没有人在里边工作,那么这个机构也就是个空架子。而谁来在这个机构中进行工作,执行程序完成功能呢,那就是下边要说的线程。

    线程:线程就充当是进程的执行者,是线程给程序赋予了运行下去的意义,每个线程都必然会有一个线程,从一开始就存在的,否则这个程序就毫无意义,会被回收释放资源。这个线程一般就被叫做主线程,主线程可以去开启关闭新的线程,就好比主线程就是这个机构的总裁,他有人员的任命权。

    其实在实际中,开启一个进程,系统会去为这个进程进行资源分配,编译部署代码,搭建好可用的环境和架构。进程并不执行什么,它只是占据应用程序所使用的地址空间。为了让进程完成一定的工作,进程必须至少占有一个线程,正是这个线程负责包含进程地址空间中的代码。

    对于线程和进程有一个较好的比喻:

        单进程单线程:一个人在一个桌子上吃菜。

单进程多线程:多个人在同一个桌子上一起吃菜。

吃完菜就相当于我们这个程序要完成的任务,假如我们的菜有很多,第一个例子,我们这个桌子只有一个位置,只能一个人吃饱后换下一个人吃,这样一个一个人的去把整个菜去吃完,这样的速度很慢。第二个例子,我们一个桌子上有很多的位置,我们就可以一起去吃,这样我们的菜吃完的速度就会大大加快,但是在做的这些人很可能去争抢同一个菜,这样我们就发生了矛盾。

速度就是我们追求的性能,矛盾就是我们要面对的多线程的问题。所以我们要在用到的时候进行多方面的考虑,下边我们将了解线程的并发和并行

在了解之前我们先了解一下各个系统中的线程和进程

NO2. Windows和linux系统中的线程和进程

    对于 Windows 系统来说,【开桌子】的开销很大,因此 Windows 鼓励大家在一个桌子上吃菜。因此 Windows 多线程学习重点是要大量面对资源争抢与同步方面的问题。

对于 Linux 系统来说,【开桌子】的开销很小,因此 Linux 鼓励大家尽量每个人都开自己的桌子吃菜。这带来新的问题是:坐在两张不同的桌子上,说话不方便。因此,Linux 下的学习重点大家要学习进程间通讯的方法。

开桌子的意思是指创建进程。开销这里主要指的是时间开销。
可以做个实验:创建一个进程,在进程中往内存写若干数据,然后读出该数据,然后退出。此过程重复 1000 次,相当于创建/销毁进程 1000 次。在我机器上的测试结果是:
UbuntuLinux:耗时 0.8 秒
Windows7:耗时 79.8 秒
两者开销大约相差一百倍。

    这意味着,在 Windows 中,进程创建的开销不容忽视。换句话说就是,Windows 编程中不建议你创建进程,如果你的程序架构需要大量创建进程,那么最好是切换到 Linux 系统。

大量创建进程的典型例子有两个,一个是 gnu autotools 工具链,用于编译很多开源代码的,他们在 Windows 下编译速度会很慢,因此软件开发人员最好是避免使用 Windows。另一个是服务器,某些服务器框架依靠大量创建进程来干活,甚至是对每个用户请求就创建一个进程,这些服务器在 Windows 下运行的效率就会很差。这"可能"也是放眼全世界范围,Linux 服务器远远多于 Windows 服务器的原因。

    如果你是写服务器端应用的,其实在现在的网络服务模型下,开桌子的开销是可以忽略不计的,因为现在一般流行的是按照 CPU 核心数量开进程或者线程,开完之后在数量上一直保持,进程与线程内部使用协程或者异步通信来处理多个并发连接,因而开进程与开线程的开销可以忽略了。

另外一种新的开销被提上日程:核心切换开销。

现代的体系,一般 CPU 会有多个核心,而多个核心可以同时运行多个不同的线程或者进程。当每个 CPU 核心运行一个进程的时候,由于每个进程的资源都独立,所以 CPU 核心之间切换的时候无需考虑上下文。当每个 CPU 核心运行一个线程的时候,由于每个线程需要共享资源,所以这些资源必须从 CPU 的一个核心被复制到另外一个核心,才能继续运算,这占用了额外的开销。换句话说,在 CPU 为多核的情况下,多线程在性能上不如多进程。因而,当前面向多核的服务器端编程中,需要习惯多进程而非多线程。

好了下面我们来了解一下线程的并发和并行吧。

NO3.线程的并发和并行

    线程的并发:

        并发,其实就是一个CPU快速的在几个进程间进行切换,程序也会进行中断运行中断运行。

    并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序(或线程)之间多路复用,并发性是对有限物理资源强制行使多用户共享以提高效率。就好比建一个水井大家去打水,和每个人建一个井去打水一样。

    微观角度:所有的并发处理都有排队等候,唤醒,执行等这样的步骤,在微观上他们都是序列被处理的,如果是同一时刻到达的请求(或线程)也会根据优先级的不同,而先后进入队列排队等候执行。

    宏观角度:多个几乎同时到达的请求(或线程)在宏观上看就像是同时在被处理。

    线程的并行:

线程的并发是真正意义上的多个线程同时一起运行,在同一时间并行的线程每个线程都占用一个cpu的资源,所以并行是发生在多核cpu上的。

引用Rob Pike的经典描述 :

并发是同一时间应对(dealing with)多件事情的能力;

并行是同一时间动手做(doing)多件事情的能力。

而在我们应用多线程的时候我们也会面对一些问题,就像之前我们提到的多个人抢一个菜这种情况,下面我们来看一下我们将要面对的问题。

NO4.多线程需要面临的问题及带来的好处

    对已一些类型的程序来说,多线程技术可以极大的提升其性能,然而就编程来说"多线程"比"多任务"更加具备挑战性。同一个程序执行了多个线程,这导致这些线程也必然会存在同时读写相同内存的可能性,这将导致许多在单线程程序中不会存在的错误。这其中的一些错误在单CPU的机器上不会发生,因为多个线程并没有真正的"同时"被执行,而是快速的在其中进行切换。然而现在大多数的机器都是多CPU的,这意味着不同的线程将真正的被不同的CPU同时执行。

    这样我们就会面临一些问题,例如:如果一个线程读一块内存的同时另一个线程写这个线程,那么第一个读线程将读到什么数据?是写之前的旧值?还是写线程写的新值?还是新旧值的某种运算结果?另一种情况,当两个线程同时写一块内存,写过之后这块内存将是什么值?是每个线程单独的写入的值还是二者的混合?

如果不合理预防,以上几种情况都可能发生,即程序的行为将无法被预测,程序的运行结果每次运行都将不一样。因此作为程序员必须知道如何对这些问题做出合理的预防措施 - 这意味着要学会如何控制线程对于内存、文件、数据库等资源的使用权限。

尽管多线程编程充满调整,但其仍被使用的原因是多线程可以带来很多好处,其中的一些如下:

  • 更有效的资源利用
  • 一些情况下程序设计将变得更加简单
  • 使程序具有更好的响应性

与单线程相比,多线程不仅仅带来了好处,也有其代价。不要因为你能并行化一个应用你就这样做,除非你充分的明白这样做带来的好处要多于其付出的代价。当你存在疑问时,试着衡量程序的性能和响应性,而不是靠着猜测。

对于我们面临的问题,我们可以建立各种模型来帮助我们来解决。

NO5.线程模型

    1.并发模型与分布式系统的相似性

    并发模型和分布式系统中应用的不同架构具有相似性。在一个并发系统中,不同的线程间互相通信。在一个分布式系统中,不同的进程(可能在不同的计算机上)间也会相互通信。这两者本质上是相当类似的,这也是为什么不同的并发模型和分布式系统中应用的不同架构如此相似。

当然分布式系统有其独特的额外难点,例如网络中断,或者结点失效等。但一个大服务器上运行的并发系统也可能遇到类似的问题,例如CPU故障,网卡故障,磁盘故障等。尽管上述故障发生的可能性很低,但理论上是存在这些情况的。

由于并发模型和分布式系统架构的相似性,二者经常相互借鉴。例如,不同线程间的任务分配模型类似于分布式系统的负载均衡模型。二者的异常处理技术也是类似的,例如日志(logging)、故障切换(fail-over)和等幂性任务(idempotency of jobs)等。

2.并行工作者模型(Parallel workers model)

并行工作者模型是将到来的任务分配给不同的工作者来执行,如下图所示:

在并行工作者并发模型中一个"委托者"(delegator)将到来的任务分配给不同的工作者。每个工作者完成自己被分配的全部任务。不同的工作者是在不同的线程中(也可能是不同的CPU中)以并行的方式运行。

举个生活中的例子,如果一个汽车生产厂才用了并行工作者模型,那么每辆汽车将由一个工人负责完成制造。这需要每个工人都有汽车的生产说明书,并且从头至尾的完成每个生产细节。

并行工作者并发模型是Java应用中最常用的一种并发模型(尽管这个情况在改变)。在java.util.concurrent包中的很多并发工具类的目的是为了应用这个模型。在Java企业版的服务器应用中,也可以看到这个模型的踪迹。

2.1并行工作者模型的优点

并行工作者模型的优点是其原理易于理解,如果想增加并行化规模只需要增加工作者的个数即可。

例如,假如你正在实现一个网络爬虫,你可以用n个工作者(线程),每个工作者用来获取一定数目的网页。然后通过调整n来看究竟用几个线程可以获得最短的总运行时间(最高的运行性能)。由于网络爬虫程序是一个I/O密集型的任务,所以最终结果很可能是一个CPU中可以运行多个爬虫线程。这种情况下,如果一个CPU只有一个爬虫线程将会浪费CPU资源,因为下载数据通常会产生大量的CPU等待时间的。

2.2并行工作者模型的缺点

并行工作者模型在其简单的外表下隐藏了数个缺点,这里我仅列举其中最明显的几个。

2.2.1 共享状态将使复杂性增加

一旦并行工作者模型隐含了共享状态,问题将变得复杂。当线程获取共享数据时,必须通过某种方式使共享数据的变化对于其他线程是可见的(将其推送至主内存,而不仅仅是保存在执行这个线程的CPU的缓存中)。线程间需要避免竞争、死锁和许多其他的共享状态并发问题。

此外,当线程间相互等待获取共享数据的时候,程序的并行性也被削弱了。许多的并发式数据结构都是"阻塞"式的,这意味着在指定的时间内,只有一个或者有限的线程可以访问这些数据。这将导致对于这些共享型数据结构的竞争状态,而从本质上说高度的竞争将导致获取共享数据的代码在一定程度上的串行化。

2.2.2 无状态的工作者

共享的状态可以被系统中的其他线程进行修改,因此工作者必须在每次需要共享数据的时候重读这些数据,来保证他所获得的数据是最新的。这对于无论共享状态是保存在内存中还是保存在外部数据库中都是适用的。如果一个工作者不在其内部保存共享状态(而是每次都重新读取最新的数据),那么我们称其为无状态的。

每次都重新读取数据将使得程序变慢,尤其是从外部数据库中读取的情况。

2.2.3 任务顺序的非确定性

并行工作者模型的另一个缺点是其各个任务的执行顺序是非确定性的,没有办法保证那个任务先被执行那个任务后被执行。任务A可能比任务B先被分配给工作者,然而任务B却可能要先于任务A被执行。

并行工作者模型的不确定性导致了很难在固定的时间点推理出系统的状态,更不用说想保证一个任务在另一个任务之前被率先执行(如果这可以实现的话,可以说是难上加难)。

3. 流水线模型(Assembly line model)

流水线并发模型,在不同的平台/圈子中,这个模型也具有其他的名字,如反应式系统(reactive system)或事件驱动系统(event driven system)。下图是流水线并发模型的一个图示:

工作者被组织成沿着流水线进行工作,每个工作者仅完成全部任务的一小部分。当一个工作者完成了自己的部分,其下一个工作者将继续完成下一个部分的工作。

每个工作者在其自己的线程中运行,和其他的工作者没有状态上的共享。所以流水线模型有时也被称为无共享并发模型。

具有流水线并发模型的系统经常被设计为使用"非阻塞"的I/O,其含义是当一个工作者开始了一个I/O操作(例如读取文件或读取网络数据)该工作者并不等待I/O操作结束。由于I/O操作太过缓慢,所以等待I/O操作实际是在浪费CPU资源。在I/O操作的同时CPU可以被用来做一些其他的事情。当I/O操作结束后,其结果(例如读取到的文件数据或者写数据的状态返回)将被传递给另一个工作者。

如果使用了非阻塞的I/O,那么I/O操作决定了两个工作者间的界限。一个工作者尽可能的完成任务,直到他不得不开始一个I/O操作。随后他放弃对任务的控制权。当I/O操作完成后,流水线中的下一个工作者继续完成任务,直到他也不得不开始I/O操作。

3.1 流水线模型的优点

3.3.1 无共享状态

工作者们不共享任何状态的事实意味着在实现流水线模型时无须考虑共享状态引起的许多并发问题(竞争、死锁等)。这使得流水线模型中的任务者实现起来更加简单。在实现一个工作者时,这个工作者仿佛是处理整个任务的唯一线程 - 实际上变成了单线程编程。

3.3.2 工作者是有状态

由于工作者知道没有其他的线程会改变其数据,这样一来工作者可以被设计为有状态(Stateful)的。这里的"有状态"指的是工作者可以将其需要操作的数据存在内存中,并将最终的处理后的结果写回到外部存储系统中。一个有状态的工作者通常比无状态的工作者运行速度更快。

3.3.3 更符合硬件特性

单线程编程具有运行更符合底层硬件运行特性的优势。首先,当你能假设你的代码以单线程模式实现时,通常你可以设计出更加优化的数据结构和算法。

3.3.4 可以获知任务的执行顺序

在实现流水线并发模型时,我们可以以某种方式保证任务执行的先后顺序。保证执行顺序可以使在某个时间点获知系统状态变得更加简单。更进一步,所有到来的任务可以写进日志。这个日志可以被随后用来重建系统的状态,以防系统中任何部分的错误。

想要实现保证任务顺序并不一定简单,但通常是可以实现的。如果能做到,这将大大简化备份、数据存储等工作,因为这都可以通过日志文件来实现。

3.2 流水线模型的缺点

流水线并发模型的主要缺点是执行一个任务时通常需要涉及多个工作者,而这将导致工程中的过多的类的个数。进而,对于给定的任务,想弄清楚究竟哪段代码在执行他将变得更加困难。

同时,编码工作也可能很困难。工作者的代码很多时候被写成回调句柄(callback handler)的形式。拥有太多的回调句柄的代码将会成为所谓的"回调地狱"(callback hell),即很难从这些回调中还原并跟踪顶层函数的真实含义,并且也很难确定是否每个回调函数都有其所需数据的访问权限。

对于并行工作者模型来说,这一点是不足为虑的。你只需要打开一个工作者的实现代码并且从头至尾阅读一边。当然,并行工作者也可能被展开成多个不同的类,但是其执行过程是很容易从代码中读懂的。

4.函数并行化模型

函数并行化模型的基本思想是:通过函数调用来实现你的代码。函数可以被看作是"代理"(agents)或者"行动者"(actors),并且相互之间发送消息,就好像上面流水线模型中所叙述的一样。当一个函数调用另一个函数,这很类似与发送一个消息。

所有被传递给函数的参数都被复制成相应的副本再传入函数中,所以接收函数外部的所有实体都无法再对数据进行操作。这种复制是为了在本质上避免共享数据的竞争问题。这使得函数的执行类似于一个原子操作(atomic operation)。每个函数调用相对于其他函数都被独立地执行。

当一个一系列并行化函数可以被单独调用执行时(即他们之间在执行的过程中不存在数据交互等耦合),每个函数都可以在多个独立的CPU中执行。这意味着利用并发函数式模型实现的算法可以在不同的CPU中并行的运行。

随着Java 7的发布,我们可以使用java.util.concurrent包中的ForkAndJoinPool来实现类似于函数并行化模型的代码。随着Java 8的发布,我们有了并行流(streams),使得对于大型的集合(collection),其迭代器可以实现并行化。值得注意的是,有一些开发者对ForkAndJoinPool持批判态度(在我的ForkAndJoinPool教程中你可以找到具体的链接)。

函数并行化的难点在于对并行化的函数的深入了解。几个跨CPU协作的函数通常需要额外的在管理时间上的损耗,一个函数所完成的问题的规模需要足够大才能抵消这种损耗。如果调用函数的任务规模过小,并行化反而会比单线程更慢。

就我个人的理解(可能并不正确),你可以自己利用反应式/事件驱动模型来实现一个模型,并且将一个完整的任务进行拆分,其效果和函数并行化模型很相似。然而,前者却可以更加精准的控制并行化的部分和并行化的程度(个人观点)。

此外,如果想让将一个任务拆分和由多个CPU协作所产生的额外时耗变得合理和有意义,其条件是仅当该任务是程序的唯一执行的工作。然而,如果程序还同时并发的执行许多其他的任务(例如网络服务、数据库服务和许多其他事要做),试着并行化其中单一的任务并无任何意义。计算机的其他CPU总是忙着做其他工作,所以用一个更慢的函数并行化任务来拖慢这些CPU是没有意义的。这时如果用流水线并发模型可能会更好,因为流水线模型具有更少的额外损耗并且和底层硬件契合的更佳。

原创粉丝点击