3.4 并行编程概念浅析
可能从一开始,你还是有许多的疑惑,所以,在进一步学习并发编程之前,我们需要整理一下相关概念,以便在以后工作学习中减少不必要的困惑。
1. 并发,并行
- 并发性(concurrency),是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。
- 并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。
- 并发和并行的区别就是一个处理器同时处理多个任务和多个处理器或者是多核的处理器同时处理多个不同的任务。
- 前者是逻辑上的同时发生(simultaneous),而后者是物理上的同时发生
打个比方:并发和并行的区别就是1个人同时吃3个馒头和3个人同时吃3个馒头。
下图反映了一个包含8个操作的任务在一个有两核心的CPU中创建四个线程运行的情况。假设每个核心有两个线程,那么每个CPU中两个线程会交替并发,两个CPU之间的操作会并行运算。单就一个CPU而言两个线程可以解决线程阻塞造成的不流畅问题,其本身运行效率并没有提高,多CPU的并行运算才真正解决了运行效率问题,这也正是并发和并行的区别。
2. 多线程编程/多进程编程的区别
我们最常用的多进程是PC上各个不同的应用,对线程可能是某个应用的不同模块。 这样设计的原因是,各个不同的应用之间不需要或者很少需要共享数据,而且也要保证自己应用的数据不能随便被其他应用修改,因此采用多进程是合理的。
不过多进程间通信是件比较麻烦的事,需要经过内核,比如管道,消息队列,共享内存,信号量,socket这些都是由内核提供,因此各进程间有一个对应的映射。
多线程的优势在于可以共享所在进程的数据段,和堆段,因此不同模块为了保证可以并发,但是二者之间又需要通信,传递数据,这时候多线程的优势就有显现了。
针对相同应用
不同应用可以有不同进程,那么同一应用要保证并发,应该如何处理呢?
这个要因情况而定,比如网络服务器的并发,当服务器并发不需要写入数据时,其实多进程与多线程差别不大,因为进程采用 copy-to-wirte 技术,也就说如果不写入数据,子进程并不会拷贝。
父进程的数据段和堆栈段。多进程可以保证数据的安全,但是多进程同时写入,这个也是需要考虑的同步的问题。
对于多线程来说,线程间的同步可能就要容易的多,如果有大量的写入操作。但是多线程的弊端在于一旦线程挂掉或者内存泄露。会导致整个进程都崩溃。多进程则不会。
总结:何时使用多进程/线程,归根到底是内存空间与数据安全的问题。比如对于安全性要要求不那么高的需求来说,个人更倾向于多线程。
3. 阻塞,非阻塞
首先,阻塞这个词来自操作系统的线程/进程的状态模型中,如下图:
一个线程/进程经历的5个状态,创建,就绪,运行,阻塞,终止。各个状态的转换条件如上图,其中有个阻塞状态,就是说当线程中调用某个函数,需要IO请求,或者暂时得不到竞争资源的,操作系统会把该线程阻塞起来,避免浪费CPU资源,等到得到了资源,再变成就绪状态,等待CPU调度运行。
阻塞调用 指调用结果返回之前,调用者会进入阻塞状态等待。只有在得到结果之后才会返回。
比如 socket 的 recv(),调用这个函数的线程如果没有数据返回,它会一直阻塞着,也就是 recv() 后面的代码都不会执行了,程序就停在 recv() 这里等待,所以一般把 recv() 放在单独的线程里调用。
非阻塞调用 指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
比如非阻塞socket 的 send(),调用这个函数,它只是把待发送的数据复制到TCP输出缓冲区中,就立刻返回了,线程并不会阻塞,数据有没有发出去 send() 是不知道的,不会等待它发出去才返回的。
拓展
如果线程始终阻塞着,永远得不到资源,于是就发生了死锁。
比如A线程要X,Y资源才能继续运行,B线程也要X,Y资源才能运行,但X,Y同时只能给一个线程用(即互斥条件)用的时候其他线程又不能抢夺。
- A 有 X,等待 Y。
- B 有 Y,等待 X。
于是A,B发生了循环等待,造成死锁。给用户的感觉就是程序卡着不动了。
在写代码的时候要特别注意共享资源的使用,用信号量控制好,避免造成死锁。
阻塞和挂起
阻塞是被动的,比如抢不到资源。挂起是主动的,线程自己调用 suspend() 把自己退出运行态了,某些时候调用 resume() 又恢复运行。
线程执行完就会被销毁,如果不想线程被频繁的创建,销毁,怎么办?可以给线程里面写个死循环,或者让线程有任务的时候执行,没任务的时候挂起,就像iOS中的 runloop 机制一样。线程就不会随便的终止了。
4. 同步与异步
同步和异步,这两个概念与消息的通知机制有关。也就是同步与异步主要是从消息通知机制角度来说的。
基本概念描述
同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态可以保持一致。
所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
消息通知
异步的概念和同步相对。当一个同步调用发出后,调用者要一直等待返回消息(结果)通知后,才能进行后续的执行;当一个异步过程调用发出后,调用者不能立刻得到返回消息(结果)。实际处理这个调用的部件在完成后,通过状态、通知和回调来通知调用者。
这里提到执行部件和调用者通过三种途径返回结果:状态、通知和回调。使用哪一种通知机制,依赖于执行部件的实现,除非执行部件提供多种选择,否则不受调用者控制。
如果执行部件用状态来通知,那么调用者就需要每隔一定时间检查一次,效率就很低。
如果是使用通知的方式,效率则很高,因为执行部件几乎不需要做额外的操作。至于回调函数,其实和通知没太多区别。
场景比喻
举个例子,比如一般去银行办理业务,可能会有两种方式:
- 选择排队等候;
- 取1个有我号码的小纸条,等到排到我这一号时由柜台的人通知我,才去办理业务;
第一种:前者(排队等候)即同步等待通知,也就是我要一直在等待银行办理业务;
第二种:后者(等待别人通知)就是异步等待消息通知。在异步消息处理中,等待消息通知者(在这个例子中就是等待办理业务的人)往往注册一个回调机制,在所等待的事件被触发时由触发机制(在这里是柜台的人)通过某种机制(在这里是写在小纸条上的号码,喊号)找到等待该事件的人。
(5)回调
回调是在等待某一件事情完成之后,再运行一个回调函数;使用回调主要是将父函数的执行结果通知给回调函数进行处理。以下是一个典型的回调函数:
function (dothings, callback) {
after dothings;
callback;
}
我们知道同步编程都是等待前面运行完才会接着运行,所以不用担心依赖变量不存在。 而异步编程非阻塞特点,所以有变量依赖的时候都得写到回调函数里面去,回调算是异步函数中为了同步执行而加入的特性。 但是必须搞清楚,异步与回调并没有直接的联系,回调只是异步的一种实现方式,同步里面也有回调。
可能混淆的地方
回调不等同于异步,不回调也不等同于同步。简单概念如下:
- 多进程、多线程、微线程(协程)都是为了实现并行化而出现的解决方案;
- 同步与异步是并行执行中协作的形式;
- 回调是一种比较便于并行化以及异步化的表现逻辑;
- 并行化中对共用资源的使用存在阻塞与非阻塞之分。
这事得从调度说起:我们知道 CPU,一个核现在还是只跑一路流程,一条指令队列的。不过要做的事情往往不止一件,并且需要同时(至少看起来同时)做,于是就出现了进程,一种由OS和CPU协同的形式上的拟并行化。
在进程/线程切换时,上下文会先存起来,然后读取另一个进程的上下文,执行一段后再转。进程/线程调度是以CPU来实现的,从代码的角度来说,我们无法控制其切换的时间点。
对于如同js/node的异步、python的协程、erlang的微线程,其实都是解释器(运行时)保持了一个事件队列,所有的异步调用都不过是在事件队列里新加事件,回调等也不过是加事件的形式。换句话说,如果代码里没有任何允许切换上下文到点(没有外部异步调用,没有显式yield/await),它就不会自动切换线程。
最后总结一下:回调并行在 python 中之所以可以并行,是因为解释器/运行时做了相应的工作;回调实现的并行是通过代码控制的并行;多线程、多进程的并行是OS+CPU调度的并行,不需要加额外的东西也能表现出并行化;回调、协程之类的并行,是代码管理的伪并行化,是需要手动切换的(至少指明可以切换的地方)。
(6)小结
以上边上所有内容,不过,讲到同步与异步,我们还不得不提及协程,这里,我们会在以后进行介绍。