Python 进程和线程
在学习 Python 多线程编程之前,小伙伴们必须要把 “进程” 和 “线程” 这 2 个概念理解透彻了。
1. 进程是什么?
在介绍进程之前,我们需要知道什么是多任务。所谓的多任务,简单点来说,就是操作系统能够执行多个任务。比如在咱们的 Windows 系统中,能够同时看电影、聊天、浏览网页等。其中,每一个任务就是一个进程。
实际上,我们可以使用 “Ctrl + Shift + Esc” 组合键打开任务管理器,就可以查看系统正在执行的进程。在下图中,此时系统的进程有 8 个。

2. 线程是什么?
进程,又被称之为 “重量级进程”。线程,又被称之为 “轻量级进程”。一个应用程序至少有一个进程,一个进程至少有一个线程。在一个进程中,至少要有一条线程作为这个程序运行的入口点。其中,这条线程又被称之为 “主线程”。
我们一定要清楚,进程与进程之间是相互独立。在多进程中,即使是同一个变量,都会各自有一个备份在每个进程中,互不影响。但是在同一个进程中,线程与线程之间是内存共享的,所有线程共享所有的变量。
从上面也可以知道,由于进程间是独立的,因此一个进程的崩溃不会影响其他进程。而线程是包含在进程之内的,线程的崩溃就会引发进程的崩溃,继而导致同一个进程内其他线程也崩溃。
线程和进程最主要的区别,是在于它们管理操作系统资源的不同。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的栈和局部变量,但线程之间没有单独的地址空间,一个线程终止就等于整个进程终止,所以多进程的程序要比多线程的程序健壮。
一个程序采用多线程处理,可以使得并发性更高,效率更快。在多线程中,多个线程共享内存,从而极大地提高了程序的运行效率。对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
多线程编程,对于具有以下特点的任务而言是非常理想的:
- 本质上是异步的。
- 需要多个并发活动。
- 每个活动的处理顺序可能是不确定的,或者说是随机的、不可预测的。
对于初学者来说,进程和线程的概念比较抽象。建议小伙伴们把整一章学完了,再回到这里看一下就非常清楚了。
Python 多线程编程
在 Python3 中,我们有 2 种方式来实现线程,一种是 “_thread 模块”,另外一种是 “threading 模块”。其中,_thread 是低级模块,threading 模块是高级模块。由于 threading 模块中包含 _thread 模块所有的方法,因此我们只需要掌握 threading 模块就可以了。事实上,在实际开发中,对于多线程编程,我们也是不推荐使用 _thread 模块的,而是推荐使用 threading 模块。
1. Python 多线程处理
在 Python 中,threading 模块为我们提供了一个 Thread 类,我们可以通过这个类来创建一个线程对象。
语法:
import threading
t = threading.Thread(target=函数名 , args=参数元组)说明:
Thread 类常用参数有 2 个:target 和 args。参数 target 取值是一个函数名,参数 args 取值是一个参数元组。
示例 1:Python 使用单线程
import time
def loop1():
print('开始第1个循环:', time.ctime())
time.sleep(4)
print('结束第1个循环:', time.ctime())
def loop2():
print('开始第2个循环:', time.ctime())
time.sleep(2)
print('结束第2个循环:', time.ctime())
if __name__ == '__main__':
loop1()
loop2()运行结果如下。
开始第 1 个循环:Mon Dec 3 19:05:34 2018
结束第 1 个循环:Mon Dec 3 19:05:38 2018
开始第 2 个循环:Mon Dec 3 19:05:38 2018
结束第 2 个循环:Mon Dec 3 19:05:40 2018分析:
这里我们在一个单线程程序中连续执行 2 个循环,只有当第 1 个循环结束后,才能开始第 2 个循环,总共消耗的时间是每个循环所有时间之和,也就是 6 秒钟。
可能这里很多小伙伴对使用 time.sleep(4)、time.sleep(2) 这两句代码不理解,不知道它们放到这里究竟有什么意义。其实你可以这样去理解:一个循环相当于 “应用程序的一部分”,time.sleep(4) 相当于 “这部分执行的时间”。只是我们为了方便讲解,使用了 time.sleep(4) 简化了这个应用程序的内部逻辑而已。
示例 2:Python 使用多线程
import time,threading
def loop1():
print('开始第1个循环:', time.ctime())
time.sleep(4)
print('结束第1个循环:', time.ctime())
def loop2():
print('开始第2个循环:', time.ctime())
time.sleep(2)
print('结束第2个循环:', time.ctime())
if __name__ == '__main__':
t1 = threading.Thread(target=loop1, args=())
t1.start()
t2 = threading.Thread(target=loop2, args=())
t2.start()运行结果如下。
开始第 1 个循环:Mon Dec 3 19:08:00 2018
开始第 2 个循环:Mon Dec 3 19:08:00 2018
结束第 2 个循环:Mon Dec 3 19:08:02 2018
结束第 1 个循环:Mon Dec 3 19:08:04 2018分析:
在这个例子中,我们使用了多线程来处理这两个循环。从输出的结果可以看出,执行完两个循环我们只用了 4 秒,也就是最长循环的执行时间加上其他所有开销的时间之和。相对于单线程的 6 秒,我们整整节省了 2 秒。其中,睡眠 4 秒和睡眠 2 秒这两个代码片段是并发执行的,这样有助于减少整体的运行时间。
此外,如果 args 参数的值是一个空元组,此时这个参数是可以省略的。也就是说,下面两句代码是等价的。
t1 = threading.Thread(target=loop1, args=())
t1 = threading.Thread(target=loop1)示例 3:Python 多线程的特点
import threading
def test(x, y):
for i in range(x, y):
print(str(i * i) + ';')
# 第 1 个线程
t1 = threading.Thread(target=test, args=(1, 6))
t1.start()
# 第 2 个线程
t2 = threading.Thread(target=test, args=(6, 11))
t2.start()运行结果如下。
1;36;
4;49;
9;64;
16;81;
25;100;分析:
在这个例子中,我们首先定义了一个函数 test()。在函数的内部,我们使用了一个 for 循环来输出指定范围内某个数的平方。从输出结果可以很直观地看出来,多线程处理的特点是并发执行。
了解了多线程的特点之后,接下来我们就不再对比单线程和多线程了,而是介绍怎么来使用多线程。
2. 线程等待
在 Python 中,我们可能会碰到这样的情况:某个线程执行了一部分,然后它得等待另外一个线程执行完成后,才能执行接下来部分。想要实现这样的效果,此时我们需要借助 Thread 类的 join() 方法。
语法:
t.join()说明:
对于 join() 方法的使用,具体是这样的:首先我们有两个线程,即 A 和 B。如果在线程 A 中调用 B.join(),那么线程 A 执行到 B.join() 这一句代码时,就会开始等待。然后直到线程 B 执行完了,线程 A 才会执行接下来的部分。
这里我们一定要记住,join() 方法是在某一个线程的内部来调用的。
示例 4:线程等待
import time,threading
def loop1():
print('开始第1个循环')
time.sleep(4)
print('结束第1个循环')
def loop2(t1):
print('开始第2个循环')
t1.join() # 线程等待
time.sleep(2)
print('结束第2个循环')
if __name__ == '__main__':
t1 = threading.Thread(target=loop1, args=() )
t1.start()
t2 = threading.Thread(target=loop2, args=(t1,))
t2.start()运行结果如下。
开始第 1 个循环
开始第 2 个循环
结束第 1 个循环
结束第 2 个循环分析:
在这个例子中,我们使用了多线程使得 t1 和 t2 并行执行,不过由于在 t2 中调用了 t1.join(),因此 t2 需要等待 t1 执行完了才能执行后面部分。也就是先结束第 1 个循环,再结束第 2 个循环。因此输出的顺序为:
开始第 1 个循环
开始第 2 个循环
结束第 1 个循环
结束第 2 个循环如果删除 t1.join() 这一句代码,也就是不采用线程等待。由于 t1 和 t2 是并行执行的,t1 执行时间为 4 秒,t2 执行时间为 2 秒,那么 t2 会比 t1 先执行完。也就是先结束第 2 个循环,再结束第 1 个循环。此时输出顺序就会变成:
开始第 1 个循环
开始第 2 个循环
结束第 2 个循环
结束第 1 个循环从上面这个例子,小伙伴们也可以很直观看出来线程等待具体是怎么一回事了。
此外我们还要注意一下元组的写法。在下面 2 种写法中,第 1 种写法是正确的,第 2 种写法是错误的。这是因为 args 参数的值是一个元组,只有一个元素的元组应该写成 (t1,) 而不是 (t1)。很多小伙伴都会忘记这一点,这里顺便提醒一下大家。
# 正确的写法
t2 = threading.Thread(target=loop2, args=(t1,))
# 错误的写法
t2 = threading.Thread(target=loop2, args=(t1))3. 锁
我们都知道,在同一个进程中,多个线程是共享内存的,因此假如某一个变量被某一个线程改变,那么对于所有的线程来说,该变量的值是已经改变的了。
既然是共享内存,那么我们可能就会碰到这样的情况:多个线程 “同时” 对某个变量进行修改。这个时候如果不采用一定的手段来处理的话,那么就会造成这样的结果:线程 A 将变量改为 v1,线程 B 将变量改为 v2。这样问题就来了,变量最终的值是 v1 呢,还是 v2 呢?
我们举一个简单的例子:比如一个列表中所有元素都是 0,线程 A 从后往前把所有元素修改为 1,而线程 B 从前往后输出列表每一个元素。那么,可能当线程 A 开始修改的时候,线程 B 就开始输出列表了,输出就变成了一半 0 一半 1,这就造成了数据的不同步。
为了避免这种情况,我们就引入了 “锁” 的概念。“锁” 的出现,就是用于解决 “线程不同步” 的问题。其中,锁有 2 种状态,分布是 “锁定” 和 “未锁定”。每当一个线程(比如上面的线程 A)要访问共享数据时,必须先获得 “锁定” 这种状态。如果已经有别的线程(比如上面的线程 B)获得 “锁定” 状态了,那么此时就让线程 A 暂停(也就是同步阻塞),等到线程 B 访问完成并释放锁以后,再让线程 A 去访问。经过这样的处理,在输出列表时要么全部输出 0,要么全部输出 1,不会再出现一半 0 一半 1 的情况。
在 Python 中,我们可以使用 threading 模块中的 Lock 类来实现锁的功能。
语法:
l = threading.Lock() # 创建锁
l.acquire() # 设置 “锁定” 状态
l.release() # 释放 “锁定” 状态说明:
acquire() 方法用于设置 “锁定” 状态,也就是上锁。release() 方法用于释放 “锁定” 状态,也就是开锁。
示例 5:没有使用 “锁”
import time, threading
def setList():
for i in range(0, 4):
items[3-i] = 1
time.sleep(0.5)
def getList():
for i in range(0, 4):
print(items[i])
time.sleep(0.5)
if __name__ == '__main__':
items = [0, 0, 0, 0]
t1 = threading.Thread(target=setList, args=())
t1.start()
t2 = threading.Thread(target=getList, args=())
t2.start()运行结果如下。
0
0
1
1分析:
在上面这个例子中,我们定义了两个函数:setList() 和 getList()。setList() 用于设置列表所有元素为 1,getList() 用于输出列表每一个元素。由于这里没有采用锁来处理,因此最终输出结果是一半 0 一半 1。接下来,我们再看一下怎么使用 “锁” 来解除冲突情况的。
示例 6:使用 “锁”
import time, threading
def setList():
# 使用 with 语句自动管理锁定与释放
with l:
for i in range(0, 4):
items[3-i] = 1
time.sleep(0.5)
def getList():
# 使用 with 语句自动管理锁定与释放
with l:
for i in range(0, 4):
print(items[i])
time.sleep(0.5)
if __name__ == '__main__':
items = [0, 0, 0, 0]
l = threading.Lock() # 实例化 Lock 类
t1 = threading.Thread(target=setList, args=())
t1.start()
t2 = threading.Thread(target=getList, args=())
t2.start()运行结果如下。
1
1
1
1分析:
锁的实现很简单,只需要在可能会造成冲突的所有线程的 “开始处” 使用 acquire() 方法,并且 “结尾处” 使用 release() 方法就可以了。
在这个例子中,time.sleep(0.5) 这一句代码是用于增加执行时间。如果没有这一句代码,执行的时间太短,会导致结果并不直观。
接下来我们再来看一个有实际用途的例子,这个例子将要实现的功能是:假设电影院某个场次只有 100 张电影票,10 个用户同时抢购该电影票(注意这里是 “同时”),每售出一张,就显示一次剩余的电影票张数。
示例 7:没有使用 “锁”
import time, threading
def task():
global n
temp = n
time.sleep(0.1)
n = temp - 1
print('购买成功,剩余' + str(n) + '张')
if __name__ == '__main__':
n = 100 # 共 100 张票
ts = [] # 初始化一个列表
for i in range(10):
t = threading.Thread(target=task, args=())
ts.append(t)
for t in ts:
t.start()运行结果如下。
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张分析:
这里我们使用了多线程处理,由于 10 个用户是买票的时候是同时进行的,因此剩下的票数都是 99,这显然不是我们想要的结果。
示例 8:使用 “锁”
import time, threading
def task():
global n
# 使用 with 语句自动管理锁定与释放
with l:
temp = n
time.sleep(0.1)
n = temp - 1
print('购买成功,剩余' + str(n) + '张')
if __name__ == '__main__':
n = 100
# 共 100 张票
ts = []
# 初始化一个列表
l = threading.Lock() # 实例化 Lock 类
for i in range(10):
t = threading.Thread(target=task, args=())
ts.append(t)
for t in ts:
t.start()运行结果如下。
购买成功,剩余 99 张
购买成功,剩余 98 张
购买成功,剩余 97 张
购买成功,剩余 96 张
购买成功,剩余 95 张
购买成功,剩余 94 张
购买成功,剩余 93 张
购买成功,剩余 92 张
购买成功,剩余 91 张
购买成功,剩余 90 张分析:
使用了 “锁” 之后,输出的结果就是我们预期的结果了。
常见问题
1. Python 的多线程真的比单线程快吗?
在很多其他编程语言(比如 C++、Java、Go 等)中,多线程确实能极大提升所有计算的效率。但在 Python 中,有一个非常特殊的底层机制叫做 “GIL(全局解释器锁)”。
GIL 就像是一个唯一的通行证,Python 规定:同一时刻,无论你有多少个线程,真正能在 CPU 上执行的只有拿到通行证的那 1 个线程。
- 什么时候用多线程?当你的任务是 “网络请求、文件读写、下载东西” 时(即 I/O 密集型),多线程非常牛!因为线程在等待网络响应时会放下通行证,让其他线程去干活。
- 什么时候千万别用多线程?当你的任务是 “极其复杂的数学运算、图像处理” 时(即 CPU 密集型),多线程反而会因为大家互相抢通行证而变得比单线程还慢!此时,我们应该使用 “多进程(multiprocessing)” 来解决。
