进程与线程
进程(Process)和线程(Thread)都是操作系统中的基本概念,它们之间有一些优劣和差异。
1. 进程
进程是程序执行时的一个实例,是系统进行资源分配的基本单位。
所有与该进程有关的资源,都被记录在进程控制块(PCB)中。以表示该进程拥有这些资源或正在使用它们。
另外,进程也是抢占处理机的调度单位,它拥有一个完整的虚拟地址空间。
当进程发生调度时,不同的进程拥有不同的虚拟地址空间,而同一进程内的不同线程共享同一地址空间。
进程可以通过fork或spawn的方式来创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此必须通过进程间通信机制(IPC,Inter-Process Communication)来实现数据共享,具体的方式包括管道、信号、套接字、共享内存区等。
2. 线程
线程,是进程中的一个实体,是被系统独立调度和分派的基本单位。
与进程不同,线程与资源分配无关,线程自己不拥有系统资源,它属于某一个进程,并与进程内的其他线程一起共享进程的资源。
由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。
线程只由相关堆栈(系统栈或用户栈)寄存器和线程控制表TCB组成。
3. 进程与线程的关系
通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源。
但是,一个线程只属于一个进程。
进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
而且需要注意的是,线程不是一个可执行的实体。
4. 进程和线程的比较
进行和线程之间的差异可以从下面几个方面来阐述:
调度 :
在引入线程的操作系统中,线程是调度和分配的基本单位 ,进程是资源拥有的基本单位 。把传统进程的两个属性分开,线程便能轻装运行,从而可显著地提高系统的并发程度。在同一进程中,线程的切换不会引起进程的切换;在由一个进程中的线程切换到另一个进程中的线程时,才会引起进程的切换。并发性 :
在引入线程的操作系统中,不仅进程之间可以并发执行,而且在一个进程中的多个线程之间亦可并发执行,因而使操作系统具有更好的并发性,从而能更有效地使用系统资源和提高系统吞吐量。拥有资源 :
不论是传统的操作系统,还是设有线程的操作系统,进程都是拥有资源的一个独立 单位,它可以拥有自己的资源。一般地说,线程自己不拥有系统资源(只有一些必不可少的资源,但它可以访问其隶属进程的资源。系统开销:
由于在创建或撤消进程时,系统都要为之分配或回收资源,因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。进程切换的开销也远大于线程切换的开销。通信:
进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性,因此共享简单。但是线程的数据同步要比进程略复杂。
python 中的多进程 multiprocessing
Linux操作系统上提供了fork()系统调用来创建进程,调用fork()函数的是父进程,创建出的是子进程,子进程是父进程的一个拷贝,但是子进程拥有自己的PID。fork()函数非常特殊它会返回两次,父进程中可以通过fork()函数的返回值得到子进程的PID,而子进程中的返回值永远都是0。
Python的os模块提供了fork()函数。由于Windows系统没有fork()调用,因此要实现跨平台的多进程编程,可以使用multiprocessing模块的Process类来创建子进程,而且该模块还提供了更高级的封装,例如批量启动进程的进程池(Pool)、用于进程间通信的队列(Queue)和管道(Pipe)等。
# 非多进程下载
import random
import time
def download(file):
download_time= random.randint(1,10)
time.sleep(download_time)
print('%s下载完成! 耗费了%d秒' % (file, download_time))
def main():
# 非多进程,按代码顺序执行download函数
start=time.time()
download('1hello')
download('2python')
end=time.time()
print('总共耗费了%.2f秒.' % (end - start))
main()
1hello下载完成! 耗费了8秒
2python下载完成! 耗费了7秒
总共耗费了15.01秒.
从上面的例子可以看出,如果程序中的代码只能按顺序一点点的往下执行,那么即使执行两个毫不相关的下载任务,也需要先等待一个文件下载完成后才能开始下一个下载任务,很显然这并不合理也没有效率。
接下来我们使用多进程的方式将两个下载任务放到不同的进程中,代码如下所示。
# 多进程下载
import random
import time
from multiprocessing import Process
from os import getpid
def download(file):
print('启动下载进程,进程号[%d].' % getpid())
download_time= random.randint(1,10)
time.sleep(download_time)
print('%s下载完成! 耗费了%d秒' % (file, download_time))
def main1():
start=time.time()
pid1= Process(target = download , args=('1hello',))
pid1.start()
pid2= Process(target = download , args=('2python',))
pid2.start()
pid1.join()
pid2.join()
end=time.time()
print('总共耗费了%.2f秒.' % (end - start))
if __name__ == '__main__':
main1()
启动下载进程1hello,进程号[10304].
1hello下载完成! 耗费了2秒
2python下载完成! 耗费了9秒
总共耗费了9.55秒.
在上面的代码中,我们通过Process类创建了进程对象,
通过target参数我们传入一个函数来表示进程启动后要执行的代码,
后面的args是一个元组,它代表了传递给函数的参数。
Process对象的start方法用来启动进程,而join方法表示等待进程执行结束。
运行上面的代码可以明显发现两个下载任务“同时”启动了,而且程序的执行时间将大大缩短,不再是两个任务的时间总和。下面是程序的一次执行结果。
由图可知,运行时系统里面存在此进程
multiprocessing.Process(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
- target 是函数名字,需要调用的函数
- args 函数需要的位置参数,以 tuple 的形式传入。args=(1,2,'justin',)
- kwargs 函数需要的命名参数,以 dict 的形式传入。kwargs={'name':'jack','age':18}
- name 子进程的名称,默认为None
- group 参数未使用,默认为None
方法:
- star() 方法启动进程
- join() 方法实现进程间的同步,等待所有进程退出。
python 中的多线程threading
目前的多线程开发我们推荐使用threading模块,该模块对多线程编程提供了更好的面向对象的封装。我们把刚才下载文件的例子用多线程的方式来实现一遍。
# 多线程下载
from threading import Thread
import time
import random
def download(file):
print('开始下载[%s].' % file)
download_time= random.randint(1,10)
time.sleep(download_time)
print('%s下载完成! 耗费了%d秒' % (file, download_time))
def main():
start=time.time()
t1= Thread(target = download , args=('1hello',))
t1.start()
t2=Thread(target = download , args=('2python',))
t2.start()
t1.join()
t2.join()
end=time.time()
print('总共耗费了%.3f秒' % (end - start))
if __name__ == '__main__':
main()
开始下载[1hello].
开始下载[2python].
2python下载完成! 耗费了4秒
1hello下载完成! 耗费了6秒
总共耗费了6.006秒
1. 线程锁Lock
因为多个线程可以共享进程的内存空间,因此要实现多个线程间的通信相对简单,大家能想到的最直接的办法就是设置一个全局变量,多个线程共享这个全局变量即可。
但是当多个线程共享同一个变量(我们通常称之为“资源”)的时候,很有可能产生不可控的结果从而导致程序失效甚至崩溃。
如果一个资源被多个线程竞争使用,那么我们通常称之为“临界资源”,对“临界资源”的访问需要加上保护,否则资源会处于“混乱”的状态。
下面的例子演示了100个线程向同一个银行账户转账(转入1元钱)的场景,在这个例子中,银行账户就是一个临界资源,在没有保护的情况下我们很有可能会得到错误的结果。
from time import sleep
from threading import Thread
class Account():
"""
类:账户管理
"""
def __init__(self):
self._balance=0
def deposit(self,money):
"""
计算存款后的余额
"""
new_balance = self._balance + money
sleep(0.01)
self._balance = new_balance
@property
def balance(self):
return self._balance
class AddMoney(Thread):
"""
继承线程类: 存钱操作
"""
def __init__(self, account, money):
super().__init__()
self._account = account
self._money = money
def run(self):
self._account.deposit(self._money)
def main():
account = Account()
thread_pool = []
# 启动100个线程,并存1元钱
for _ in range(100):
t = AddMoney(account,1)
thread_pool.append(t)
t.start()
for t in thread_pool:
t.join()
print('账户余额为:%d 元'% account.balance)
if __name__=='__main__':
main()
账户余额为:3 元
运行上面程序会发现100个线程存钱后,账户里面的钱是小于100元的。
多个线程同时向账户中存钱时,会一起执行到new_balance = self._balance + money
这行代码,多个线程得到的账户余额都是初始状态下的0,所以都是0上面做了+1的操作,因此得到了错误的结果。
在这种情况下,“锁”就可以派上用场了。我们可以通过“锁”来保护“临界资源”,只有获得“锁”的线程才能访问“临界资源”,而其他没有得到“锁”的线程只能被阻塞起来,直到获得“锁”的线程释放了“锁”,其他线程才有机会获得“锁”,进而访问被保护的“临界资源”。下面的代码演示了如何使用“锁”来保护对银行账户的操作,从而获得正确的结果。
from threading import Lock
class AccountWithLock(object):
def __init__(self):
self._balance = 0
self._lock = Lock()
def deposit(self, money):
# 先获取锁才能执行后续的代码
self._lock.acquire()
try:
new_balance = self._balance + money
sleep(0.01)
self._balance = new_balance
finally:
# 在finally中执行释放锁的操作保证正常异常锁都能释放
self._lock.release()
@property
def balance(self):
return self._balance
def mainLock():
account = AccountWithLock()
thread_pool = []
# 启动100个线程,并存1元钱
for _ in range(100):
t = AddMoney(account,1)
thread_pool.append(t)
t.start()
for t in thread_pool:
t.join()
print('账户余额为:%d 元'% account.balance)
if __name__=='__main__':
mainLock()
账户余额为:100 元
2. GIL 全局解释器锁 (面试常考)
比较遗憾的一件事情是Python的多线程并不能发挥CPU的多核特性,这一点只要启动几个执行死循环的线程就可以得到证实了。
之所以如此,是因为Python的解释器有一个“全局解释器锁”(GIL)的东西,任何线程执行前必须先获得GIL锁,然后每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行,这是一个历史遗留问题,但是即便如此,就如我们之前举的例子,使用多线程在提升执行效率和改善用户体验方面仍然是有积极意义的。
多进程还是多线程选择?
无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?
我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这5科的作业,每项作业耗时1小时。如果你先花1小时做语文作业,做完了,再花1小时做数学作业,这样,依次全部做完,一共花5小时,这种方式称为单任务模型。如果你打算切换到多任务模型,可以先做1分钟语文,再切换到数学作业,做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务是一样的了,以旁观者的角度来看,你就正在同时写5科作业。
但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。所以,多任务一旦多到一个限度,反而会使得系统性能急剧下降,最终导致所有任务都做不好。
可以把任务分为计算密集型和I/O密集型。
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如对视频进行编码解码或者格式转换等等,这种任务全靠CPU的运算能力,虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低。计算密集型任务由于主要消耗CPU资源,这类任务用Python这样的脚本语言去执行效率通常很低,最能胜任这类任务的是C语言,我们之前提到了Python中有嵌入C/C++代码的机制。此类任务一般适合多进程架构。
除了计算密集型任务,其他的涉及到网络、存储介质I/O的任务都可以视为I/O密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待I/O操作完成(因为I/O的速度远远低于CPU和内存的速度)。对于I/O密集型任务,如果启动多任务,就可以减少I/O等待时间从而让CPU高效率的运转。此类任务一般适合多线程架构。
多进程模式最大的优点就是稳定性高,因为一个子进程崩溃了,不会影响主进程和其他子进程。(当然主进程挂了所有进程就全挂了,但是Master进程只负责分配任务,挂掉的概率低)著名的Apache最早就是采用多进程模式。
多进程模式的缺点是创建进程的代价大,在Unix/Linux系统下,用fork调用还行,在Windows下创建进程开销巨大。另外,操作系统能同时运行的进程数也是有限的,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统连调度都会成问题。
多线程模式通常比多进程快一点,但是也快不到哪去,而且,多线程模式致命的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存。在Windows上,如果一个线程执行的代码出了问题,你经常可以看到这样的提示:“该程序执行了非法操作,即将关闭”,其实往往是某个线程出了问题,但是操作系统会强制结束整个进程。
在Windows下,多线程的效率比多进程要高,所以微软的IIS服务器默认采用多线程模式。由于多线程存在稳定性的问题,IIS的稳定性就不如Apache。为了缓解这个问题,IIS和Apache现在又有多进程+多线程的混合模式,真是把问题越搞越复杂。
异步IO
考虑到CPU和IO之间巨大的速度差异,一个任务在执行的过程中大部分时间都在等待IO操作,单进程单线程模型会导致别的任务无法并行执行,因此,我们才需要多进程模型或者多线程模型来支持多任务并发执行。
现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器,它在单核CPU上采用单进程模型就可以高效地支持多任务。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。用异步IO编程模型来实现多任务是一个主要的趋势。
对应到Python语言,单线程的异步编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序。我们会在后面讨论如何编写协程。
本文部分内容来自于廖雪峰官方网站的《Python教程》