一、什么是并发?
并发(concurrency)在生活中随处可见,边走路边说话,边听歌边写代码。计算机术语中的"并发",指的是在单个系统里同时执行多个独立的活动,而不是顺序的一个接一个的执行。
对于单核 CPU 来说,在某个时刻只可能处理一个任务,但它却不是完全执行完一个任务再执行一个下一任务,而是一直在任务间切换,每个任务完成一点就去执行下一个任务,看起来就像任务在并行发生,虽然不是严格的同时执行多个任务,但是我们仍然称之为并发。真正的并发是在在多核 CPU 上,能够真正的同时执行多个任务,称为硬件并发(hardware concurrency)。
并发并非没有代价,在单核 CPU 并发执行两个任务需要付出上下文切换的时间代价。如下图:
假设 A 和 B 两个任务都被分成 10 个大小相等的块,单核 CPU 交替的执行两个任务,每次执行其中一块,其花费的时间并不是先完成 A 任务再玩成 B 任务所花费时间的两倍,而是要更多。这是因为系统从一个任务切换到另一个任务需要执行一次上下文切换,这是需要时间的(图中的灰色块)。上下文切换需要操作系统为当前运行的任务保存 CPU 的状态和指令指针,算出要切换到哪个任务,并为要切换的任务重新加载处理器状态。然后将新任务的指令和数据载入到缓存中。
二、并发的方式
2.1 多进程并发
使用并发的第一种方法,是将应用程序分为多个独立的进程,它们在同一时刻运行,就像同时进行网页浏览和文字处理一样。如下图所示,这些独立的进程可以通过常规的进程间通信机制进行通信,如管道、信号、消息队列、共享内存、存储映射 I/O、信号量、套接字等。
不过,这种进程之间的通信通常不是设置复杂,就是速度慢,这是因为操作系统会在进程间提供了一定的保护措施,以避免一个进程去修改另一个进程的数据。
缺点:
- 进程间通信较为复杂,速度相对线程间的通信更慢。
- 启动进程的开销比线程大,使用的系统资源也更多。
优点:
- 进程间通信的机制相对于线程更加安全。
- 能够很容易的将一台机器上的多进程程序部署在不同的机器上。
2.2 多线程并发
并发的另一个途径,在单个进程中运行多个线程。线程很像轻量级的进程,但是一个进程中的所有线程都共享相同的地址空间,线程间的大部分数据都可以共享。
虽然,线程之间通常共享内存,但是这种共享通常是难以建立和管理的。因为,同一数据的内存地址在不同的线程中是不相同。下图展示了一个进程中的两个线程通过共享内存进行通信。
优点:
- 由于可以共享数据,多线程间的通信开销比进程小的多。
- 线程启动的比进程快,占用的资源更少。
缺点:
- 共享数据太过于灵活,为了维护正确的共享,代码写起来比较复杂。
- 无法部署在分布式系统上。
三、为什么使用并发
主要原因有两个:关注点分离(SOC)和提高性能。事实上,它们应该是使用并发的唯一原因;如果你观察得足够仔细,所有因素都可以归结到其中的一个原因(或者可能是两个都有)。
3.1 为了分离关注点
编写软件时,通过将相关的代码与无关的代码分离,可以使程序更容易理解和测试,从而减少出错的可能性。即使一些功能区域中的操作需要在同一时刻发生的情况下,依旧可以使用并发分离不同的功能区域;若不显式地使用并发,就得编写一个任务切换框架,或者在操作中主动地调用一段不相关的代码。
考虑一个有用户界面的处理密集型应用——DVD 播放程序。这样的应用程序,应具备这两种功能:
- 一,要从光盘中读出数据,对图像和声音进行解码,之后把解码出的信号输出至视频和音频硬件,从而实现 DVD 的无误播放;
- 二,还需要接受来自用户的输入,当用户单击“暂停”、“返回菜单”或“退出”按键的时候执行对应的操作。
当应用是单个线程时,应用需要在回放期间定期检查用户的输入,这就需要把 “DVD播放” 代码和 “用户界面” 代码放在一起,以便调用。如果使用多线程方式来分隔这些关注点,“用户界面” 代码和 “DVD播放” 代码就不再需要放在一起:一个线程可以处理“用户界面”事件,另一个进行 “DVD播放”。它们之间会有交互(用户点击 “暂停”),不过任务间需要人为的进行关联。
这会给响应性带来一些错觉,因为用户界面线程通常可以立即响应用户的请求,在当请求传达给忙碌线程,这时的相应可以是简单地显示代表忙碌的光标或 “请等待” 字样的消息。类似地,独立的线程通常用来执行那些必须在后台持续运行的任务,例如,桌面搜索程序中监视文件系统变化的任务。
在这种情况下,线程的数量不再依赖CPU中的可用内核的数量,因为对线程的划分是基于概念上的设计,而不是一种增加吞吐量的尝试。
3.2 为了提高性能
在两种情况下,并发能够提高性能:
- 任务并行(task parallelism):将一个单个任务分成若干个部分,且各自并行运行,从而降低总运行时间。虽然听起来很简单,但其实是一个相当复杂的过程,设想假如各个部分之间存在很多依赖,一个部分的执行需要使用到另一个任务的执行结果,这个时候并不能很好的并行完成。
- 数据并行(data parallelism):每个线程在不同的数据部分上执行相同的操作。假如有 1000 个文件要处理,可以先将这 1000 个文件分成 10 组,可以开辟 10 个线程来并行处理,大大节省处理时间。
四、什么时候不使用并发
知道何时不使用并发与知道何时使用它一样重要。基本上,不使用并发的唯一原因就是,收益比不上成本。使用并发的代码在很多情况下难以理解,因此编写和维护多线程代码就会产生直接的脑力成本,同时额外的复杂性也可能引起更多的错误。除非潜在的性能增益足够大或关注点分离地足够清晰,能抵消所需的额外的开发时间以及与维护多线程代码相关的额外成本(代码正确的前提下);否则,别用并发。
同样地,性能增益可能会小于预期;因为操作系统需要分配内核相关资源和堆栈空间,所以在启动线程时存在固有的开销,然后才能把新线程加入调度器中,所有这一切都需要时间。如果在线程上的任务完成得很快,那么任务实际执行的时间要比启动线程的时间小很多,这就会导致应用程序的整体性能还不如直接不使用线程的方式。
此外,线程是有限的资源。如果让太多的线程同时运行,则会消耗很多操作系统资源,从而使得操作系统整体上运行得更加缓慢。不仅如此,因为每个线程都有一个 1MB 的堆栈(很多系统都会这样分配),如果是 32位 系统, 总共 4G 内存,最多支持 4096 个线程,没有多余空间作为静态数据和堆数据了。所以如果你运行了太多的线程,最终也是会出问题的。尽管线程池可以用来限制线程的数量,但这也并不是什么灵丹妙药,它也有自己的问题。
参考: