进程和线程
概念
进程就是操作系统中执行的一个程序,操作系统以进程为单位分配存储空间,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据,操作系统管理所有进程的执行,为它们合理的分配资源。进程可以通过 fork 或 spawn 的方式来创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此必须通过进程间通信机制(IPC,Inter-Process Communication)来实现数据共享,具体的方式包括管道、信号、套接字、共享内存区等。
一个进程还可以拥有多个并发的执行线索,简单的说就是拥有多个可以获得 CPU 调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核 CPU 系统中,真正的并发是不可能的,因为在某个时刻能够获得 CPU 的只有唯一的一个线程,多个线程共享了 CPU 的执行时间。使用多线程实现并发编程为程序带来的好处是不言而喻的,最主要的体现在提升程序的性能和改善用户体验。
当然多线程也并不是没有坏处,站在其他进程的角度,多线程的程序对其他程序并不友好,因为它占用了更多的 CPU 执行时间,导致其他程序无法获得足够的 CPU 执行时间。
Python 既支持多进程又支持多线程,因此使用 Python 实现并发编程主要有 3 种方式:多进程、多线程、多进程+多线程。
多进程
Unix 和 Linux 操作系统上提供了 fork 系统调用来创建进程,调用 fork() 函数的是父进程,创建出的是子进程,子进程是父进程的一个拷贝,但是子进程拥有自己的 PID 。fork() 函数非常特殊它会返回两次,父进程中可以通过 fork() 函数的返回值得到子进程的 PID ,而子进程中的返回值永远都是 0 。Python 的 os 模块提供了 fork() 函数。由于Windows系统没有 fork() 调用,因此要实现跨平台的多进程编程,可以使用 multiprocessing 模块的 Process 类来创建子进程,而且该模块还提供了更高级的封装,例如批量启动进程的进程池(pool)、用于进程间通信的队列(Queue)和管道(Pipe)等。
使用多进程和不使用的区别。
from random import randint
from time import time, sleep
# 模拟下载文件
def download_task(filename):
print('开始下载%s...' % filename)
time_to_download = randint(5, 10)
sleep(time_to_download)
print('%s下载完成! 耗费了%d秒' % (filename, time_to_download))
def main():
start = time()
download_task('Python学习.pdf')
download_task('Java学习.pdf')
end = time()
print('总共耗费了%.2f秒.' % (end - start))
if __name__ == '__main__':
main()
单进程的运行结果。
开始下载Python学习.pdf...
Python学习.pdf下载完成! 耗费了7秒
开始下载Java学习.pdf...
Java学习.pdf下载完成! 耗费了8秒
总共耗费了15.01秒.
如果程序中的代码只能按顺序一点点的往下执行,那么即使执行两个毫不相关的下载任务,也需要先等待一个文件下载完成后才能开始下一个下载任务,很显然这并不合理也没有效率。接下来我们使用多进程的方式将两个下载任务放到不同的进程中。
from multiprocessing import Process
from os import getpid
from random import randint
from time import time, sleep
# 模拟下载文件
def download_task(filename):
print('启动下载进程,进程号[%d].' % getpid())
print('开始下载%s...' % filename)
time_to_download = randint(5, 10)
sleep(time_to_download)
print('%s下载完成! 耗费了%d秒' % (filename, time_to_download))
def main():
start = time()
p1 = Process(target=download_task, args=('Python学习.pdf', ))
p1.start()
p2 = Process(target=download_task, args=('Java学习.pdf', ))
p2.start()
p1.join()
p2.join()
end = time()
print('总共耗费了%.2f秒.' % (end - start))
if __name__ == '__main__':
main()
我们通过 Process 类创建了进程对象,通过 target 参数我们传入一个函数来表示进程启动后要执行的代码,后面的 args 是一个元组,它代表了传递给函数的参数。Process 对象的 start 方法用来启动进程,而 join 方法表示等待进程执行结束。运行上面的代码可以明显发现两个下载任务“同时”启动了,而且程序的执行时间将大大缩短,不再是两个任务的时间总和。
启动下载进程,进程号[1530].
开始下载Python学习.pdf...
启动下载进程,进程号[1531].
开始下载Java学习.pdf...
Python学习.pdf下载完成! 耗费了6秒
Java学习.pdf下载完成! 耗费了7秒
总共耗费了7.01秒.
多线程
在 Python 早期的版本中就引入了 thread 模块(现在名为 _thread)来实现多线程编程,然而该模块过于底层,而且很多功能都没有提供,因此目前的多线程开发我们推荐使用 threading 模块,该模块对多线程编程提供了更好的面向对象的封装.
把刚才下载文件的例子用多线程的方式来实现一遍.
from random import randint
from threading import Thread
from time import time, sleep
# 模拟下载文件
def download(filename):
print('开始下载%s...' % filename)
time_to_download = randint(5, 10)
sleep(time_to_download)
print('%s下载完成! 耗费了%d秒' % (filename, time_to_download))
def main():
start = time()
t1 = Thread(target=download, args=('Python学习.pdf',))
t1.start()
t2 = Thread(target=download, args=('Java学习.pdf',))
t2.start()
t1.join()
t2.join()
end = time()
print('总共耗费了%.3f秒' % (end - start))
if __name__ == '__main__':
main()
我们可以直接使用 threading 模块的 Thread 类来创建线程,但是我们之前讲过一个非常重要的概念叫“继承”,我们可以从已有的类创建新类,因此也可以通过继承 Thread 类的方式来创建自定义的线程类,然后再创建线程对象并启动线程。
from random import randint
from threading import Thread
from time import time, sleep
# 线程类:模拟下载文件
class DownloadTask(Thread):
def __init__(self, filename):
super().__init__()
self._filename = filename
def run(self):
print('开始下载%s...' % self._filename)
time_to_download = randint(5, 10)
sleep(time_to_download)
print('%s下载完成! 耗费了%d秒' % (self._filename, time_to_download))
def main():
start = time()
t1 = DownloadTask('Python学习.pdf')
t1.start()
t2 = DownloadTask('Java学习.pdf')
t2.start()
t1.join()
t2.join()
end = time()
print('总共耗费了%.2f秒.' % (end - start))
if __name__ == '__main__':
main()
初步认识“锁”
因为多个线程可以共享进程的内存空间,因此要实现多个线程间的通信相对简单,大家能想到的最直接的办法就是设置一个全局变量,多个线程共享这个全局变量即可。但是当多个线程共享同一个变量(我们通常称之为“资源”)的时候,很有可能产生不可控的结果从而导致程序失效甚至崩溃。如果一个资源被多个线程竞争使用,那么我们通常称之为“临界资源”,对“临界资源”的访问需要加上保护,否则资源会处于“混乱”的状态。
比如“双十一”抢购开始的一瞬间,有个 1 元的秒杀商品有了 100 个同时发起的订单,那么就出现了有 100 个用户的账户同时向 1 个商家的账户转钱的情况,这个过程中,商家的账户就是一个“临界资源”。
from time import sleep
from threading import Thread
class Account(object):
def __init__(self):
self._balance = 0
def deposit(self, money):
# 计算存款后的余额
new_balance = self._balance + money
# 模拟受理存款业务需要0.01秒的时间
sleep(0.01)
# 修改账户余额
self._balance = new_balance
@property
def balance(self):
return self._balance
class AddMoneyThread(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()
threads = []
# 创建100个存款的线程向同一个账户中存钱
for _ in range(100):
t = AddMoneyThread(account, 1)
threads.append(t)
t.start()
# 等所有存款的线程都执行完毕
for t in threads:
t.join()
print('账户余额为: ¥%d元' % account.balance)
if __name__ == '__main__':
main()
运行上面的程序,结果让人大跌眼镜,100 个线程分别向账户中转入 1 元钱,结果居然远远小于 100 元。之所以出现这种情况是因为我们没有对银行账户这个“临界资源”加以保护,多个线程同时向账户中存钱时,会一起执行到 new_balance = self.balance + money 这行代码,多个线程得到的账户余额都是初始状态下的0
,所以都是0
上面做了 +1 的操作,因此得到了错误的结果。在这种情况下,“锁”就可以派上用场了。我们可以通过“锁”来保护“临界资源”,只有获得“锁”的线程才能访问“临界资源”,而其他没有得到“锁”的线程只能被阻塞起来,直到获得“锁”的线程释放了“锁”,其他线程才有机会获得“锁”,进而访问被保护的“临界资源”。
from time import sleep
from threading import Thread, Lock
class Account(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
class AddMoneyThread(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()
threads = []
for _ in range(100):
t = AddMoneyThread(account, 1)
threads.append(t)
t.start()
for t in threads:
t.join()
print('账户余额为: ¥%d元' % account.balance)
if __name__ == '__main__':
main()
单线程 + 异步I/O
现代操作系统对 I/O 操作的改进中最为重要的就是支持异步 I/O 。如果充分利用操作系统提供的异步 I/O 支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型。 Nginx 就是支持异步 I/O 的 Web 服务器,它在单核 CPU 上采用单进程模型就可以高效地支持多任务。在多核 CPU 上,可以运行多个进程(数量与 CPU 核心数相同),充分利用多核 CPU 。用 Node.js 开发的服务器端程序也使用了这种工作模式,这也是当下并发编程的一种流行方案。
在 Python 语言中,单线程+异步 I/O 的编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序。协程最大的优势就是极高的执行效率,因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销。协程的第二个优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不用加锁,只需要判断状态就好了,所以执行效率比多线程高很多。如果想要充分利用 CPU 的多核特性,最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。