本文从操作系统原理出发,结合代码实践讲解以下内容:什么是进程、线程、协程?他们之间是什么关系?多线程是伪多线程吗?不同应用场景如何选择技术方案?...什么是进程进程——操作系统提供的一个抽象概念,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是对指令、数据及其组织形式的描述,进程是程序的实体。程序本身没有生命周期,它只是存储在磁盘上的一些指令,程序一旦运行起来就是一个进程。当程序需要运行时,操作系统通过创建和初始化栈,将代码和所有静态数据记录到内存和进程的地址空间中(每个进程都有唯一的地址空间,如下图所示)(局部变量、函数参数和返回地址),分配堆内存和IO相关任务,当前准备工作完成,程序启动,OS将CPU的控制权交给新创建的进程,进程启动跑步。操作系统通过PCB(ProcessingControlBlock)对进程进行控制和管理。PCB通常是系统内存区中一块连续的存储区,存放着操作系统描述进程和控制进程运行所需的所有信息(进程标识号、进程状态、进程优先级、文件系统指针和每个寄存器的内容等),进程的PCB是系统感知进程的唯一实体。一个进程至少有5种基本状态:初始状态、执行状态、等待(阻塞)状态、就绪状态和终止状态。状态。执行状态:任何时候只能有一个进程处于执行状态。就绪状态:只有处于就绪状态的才会被调度到执行状态等待状态:进程等待事件完成停止状态:进程结束进程之间的切换无论是在多核还是单核系统中,一个CPU看起来在运行并发执行多个进程,这是通过在进程之间切换处理器来实现的。操作系统在不同进程之间交换CPU控制权的机制是上下文切换(contextswitch),即保存当前进程的上下文,恢复新进程的上下文,然后将CPU控制权转移给新进程。将从停止的地方继续。因此,进程轮流使用CPU,CPU由多个进程共享,使用某种调度算法来决定何时停止一个进程,转而为另一个进程提供服务。在单核CPU双进程的情况下,进程直接指定机制,在I/O中断的情况下,进行上下文切换,轮流使用CPU资源。在双核CPU、双进程的情况下,每个进程独占一个CPU核心资源并处理I/O。当O请求时,CPU被阻塞。进程间数据共享系统中的进程与其他进程共享CPU和主存资源。为了更好地管理主内存,系统现在提供了主内存的抽象概念,即虚拟内存(Virtualmemory,VM)。它是一个抽象概念,为每个进程提供每个进程都独占使用主内存的错觉。虚拟内存主要提供三种能力:把主存看成是存储在磁盘上的缓存,只保存主存中的活动区域,根据需要在磁盘和主存之间来回传输数据,通过这种方式,更有效地使用主内存为每个进程提供一致的地址空间,从而简化内存管理并保护每个进程的地址空间不被其他进程破坏。由于进程拥有自己独享的虚拟地址空间,CPU通过地址翻译将虚拟地址转换为真实的物理地址,每个进程只能访问自己的地址空间。因此,如果没有其他机制(进程间通信)的辅助,进程是无法共享数据的。以python中的多进程为例importmultiprocessingimportthreadingimporttimen=0defcount(num):globalnforiinrange(100000):n+=iprint("Process{0}:n={1},id(n)={2}".format(num,n,id(n)))if__name__=='__main__':start_time=time.time()process=list()foriinrange(5):p=multiprocessing.Process(target=count,args=(i,))#Test多进程使用#p=threading.Thread(target=count,args=(i,))#测试多线程使用进程。append(p)forpinprocess:p.start()forpinprocess:p.join()print("Main:n={0},id(n)={1}".format(n,id(n)))end_time=time.time()print("Totaltime:{0}".format(end_time-start_time))结果Process1:n=4999950000,id(n)=139854202072440Process0:n=4999950000,id(n)=139854329146064Process2:n=4999950000,ID(n)=139854202072400Process4:n=4999950000,ID(n)=139854201618960Process3:n=49999950000,ID(id,1,2,3,4}和主进程(main)有一个唯一的地址空间最是一个程序执行流程小单元是处理器调度调度的基本单元。一个进程可以有一个或多个线程。同一个进程中的多个线程会共享进程中的所有系统资源,如虚拟地址空间、文件描述符和信号处理等。etc.但是同一个进程中的多个线程都有自己的调用栈和线程本地存储(如下图所示)。系统采用PCB来完成过程的控制和管理。同样,系统为线程分配一个线程控制块TCB(ThreadControlBlock),并在线程控制块中记录所有用于控制和管理线程的信息。TCB通常包括:线程标识符一组寄存器线程运行状态优先级Level级线程专有存储区信号屏蔽同进程一样。线程也有五种状态:初始状态、执行状态、等待(阻塞)状态、就绪状态和终止状态。线程之间的切换需要像进程一样进行上下文切换。这里我就不细说了。进程和线程有很多相似之处,那么它们有什么区别呢?ProcessVSThreadProcess是一个独立的资源分配和调度单位。一个进程有一个完整的虚拟地址空间,当发生进程切换时,不同的进程有不同的虚拟地址空间。但是,同一个进程的多个线程可以共享同一个地址空间。线程是CPU调度的基本单位,一个进程包含多个线程。线程比进程小,基本上不拥有系统资源。创建和销毁线程所需的时间比进程小得多。由于地址空间可以在线程之间共享,因此需要考虑同步和互斥操作。一个线程的意外终止会影响整个进程的正常运行,但是一个进程的意外终止不会影响其他进程的运行。因此,多进程程序更安全。总之,多进程程序安全性高,进程切换开销大,效率低;多线程程序维护成本高,线程切换开销小,效率高。(Python的多线程是伪多线程,下面会详细介绍)什么是协程Coroutine(又称微线程)是比线程更轻量级的存在。管理,但完全由程序控制。协程、线程、进程的关系如下图所示。协程可以类比为子程序,但是在执行过程中,子程序可以在内部被中断,然后转而去执行其他的子程序,在合适的时候再返回继续执行。协程之间的切换不需要涉及任何系统调用或任何阻塞调用。协程只在一个线程中执行,是子程序之间的切换,发生在用户态。而且,线程的阻塞状态是由操作系统内核完成的,发生在内核态。因此,协程相对于线程节省了线程创建和切换的开销。协程中不存在同步变量写入冲突。用于保护关键块的同步原语,例如互斥量、信号量等,不需要操作系统的支持。协程适合IO阻塞,对并发要求很高。当发生IO阻塞时,协程的调度器会对其进行调度。通过yield数据流,将数据记录在当前栈上,在blockStack之后立即被线程恢复,并将阻塞的结果放到这个线程上运行。下面,我们将分析在不同的应用场景下如何选择使用Python中的进程、线程和协程。如何选择?在比较三者在不同场景下的区别之前,首先需要介绍下python的多线程(曾被程序员诟病为“伪”多线程)。那为什么会认为Python中的多线程是“伪”多线程呢?在上面的multiprocessing例子中,p=multiprocessing.Process(target=count,args=(i,))就是p=threading.Thread(target=count,args=(i,)),其他的和往常一样,运行结果如下:为了减少代码冗余和文章长度,请忽略命名和打印不规范的问题。(n)=140103573185600Process1:n=11829507727,id(n)=140103573185600Process4:n=17812587459,id(n)=140103573072912Process3:n=14424763612,id(n)=140103573185600Main:n=17812587459,id(n)=140103573072912Totaltime:0.1056210994720459n为全局变量,Main打印结果与线程打印结果相等,证明线程间共享数据。但是,为什么多线程比多进程运行时间长呢?这和我们上面说的(线程开销<<进程开销)严重出入。这就轮到Cpython(python的默认解释器)中的GIL(GlobalInterpreterLock,全局解释器锁)了。什么是GILGIL,来源于Python设计之初的考虑,为了数据安全而做出的决定(由于在内存管理机制中使用了引用计数)。如果一个线程要执行,它必须先获得GIL。因此,GIL可以看作是一张“通行证”,在一个Python进程中,只有一个GIL,得不到通行证的线程是不允许进入CPU执行的。Cpython解释器在内存管理中使用引用计数。当一个对象的引用数为0时,该对象将作为垃圾被回收。想象这样一个场景:一个进程包含两个线程,分别是线程0和线程1,两个线程都引用了对象a。当两个线程同时引用a时(不修改,不需要使用同步原语),对象a的引用计数器会同时被修改,导致计数器引用小于实质引用。执行垃圾回收时,会导致错误异常。因此,需要一个全局锁(GIL)来保证对象引用计数的正确性和安全性。不管是单核还是多核,一个进程同时只能执行一个线程(拿到GIL的线程才能执行,如下图),这就是为什么Python的multi的根本原因-线程效率在多核CPU上不高。那是不是说在Python中遇到并发需求,用多进程就万事大吉了?其实不然,软件工程有一句名言:没有银弹!什么时候使用它?常见的应用场景不外乎三种:CPU密集型:程序需要占用大量CPU进行计算和数据处理;I/O密集型:程序需要进行频繁的I/O操作;比如网络中的socket数据传输和读取;CPU密集型+I/O密集型:以上两种结合CPU密集型的情况可以和上面多处理和线程的例子进行对比,多处理的性能>多线程的性能。下面主要说明I/O密集的情况。要与I/O设备交互,最常用的解决方案是DMA。什么是DMA?DMA(DirectMemoryAccess)是系统中的一个特殊设备。它可以协调并完成从内存到设备的数据传输,中间过程不需要CPU干预。以写入文件为例:进程p1发出请求,向磁盘文件写入数据。CPU处理写请求,通过编程告诉DMA引擎数据在内存中的位置,要写入的数据大小,目标设备。CPU处理其他进程p2DMA负责将内存数据写入设备,DMA完成数据传输,中断CPU,CPU上下文从p2切换到p1,继续执行p1Python多线程性能(I/O密集型)线程Thread0先执行,线程Thread1等待(GIL的存在)Thread0收到I/O请求,转发请求给DMA,DMA执行请求Thread1占用CPU资源,继续执行CPU收到DMA的中断请求,切换到Thread0继续执行,类似于进程的执行方式,弥补了GIL带来的不足,而且由于线程的开销远小于进程,在IO密集型场景,更高性能的多线程的实践是检验真理的唯一标准。下面将针对I/O密集型场景进行测试。测试执行代码importmultiprocessingimportthreadingimporttimedefcount(num):time.sleep(1)##模拟IO操作print("Process{0}End".format(num))if__name__=='__main__':start_time=time.time()process=list()foriinrange(5):p=multiprocessing.Process(target=count,args=(i,))#p=threading.Thread(target=count,args=(i,))process.append(p)forpinprocess:p.start()forpinprocess:p.join()end_time=time.time()print("Totaltime:{0}".format(end_time-start_time))结果##多进程Process0EndProcess3EndProcess4EndProcess2EndProcess1EndTotaltime:1.383193016052246##Multi-threadProcess0EndTimeProcess3dTotalProcess4EndProcess:1.003425121307373多线程的执行性能高于多进程。你认为这是结束了吗?离得很远。对于I/O密集型程序,协程的执行效率更高,因为它是由程序自己控制的,会省去线程创建和切换带来的开销。依托Python中的asyncio应用,使用async/await语法创建和使用协程。程序代码importtimeimportasyncioasyncdefcoroutine():awaitasyncio.sleep(1)##模拟IO操作if__name__=="__main__":start_time=time.time()loop=asyncio.get_event_loop()tasks=[]foriinrange(5):task=loop.create_task(coroutine())tasks.append(task)loop.run_until_complete(asyncio.wait(tasks))loop.close()end_time=time.time()print("totaltime:",end_time-start_time)结果总时间:1.001854419708252协程的执行效率比多线程高总结本文从操作系统原理出发,结合代码实践讲解进程、线程和协程以及它们之间的关系。另外总结整理了Python实践中如何针对不同场景选择相应的解决方案,如下:CPU密集型:多进程IO密集型:多线程(协程维护成本高,读写效率高)写文件意义不大boost)CPU密集型和IO密集型:多进程+协程
