Python多线程

v2-55134722dc0bb73e0104d61d85fa7c03_r.jpg

Python中的多线程编程:一个美丽的梦

Reposted Link

Thread官方文档 转载连接:廖雪峰

Thread API

start()run()

start方法:开始线程活动。对每一个线程对象来说它只能被调用一次,它安排对象在一个另外的单独线程中调用run()方法(而非当前所处线程)。当该方法在同一个线程对象中被调用超过一次时,会引入RuntimeError(运行时错误)。

run()方法:代表了线程活动的具体方法。你可以在子类中重写此方法,以实现自己的工作目标。标准run()方法调用了传递给对象的构造函数的可调对象作为目标参数,如果有这样的参数的话,顺序和关键字参数分别从args和kargs取得。

join()

join 的原理就是依次检验线程池中的线程是否结束,没有结束就阻塞直到线程结束,如果结束则跳转执行下一个线程的join函数。调用 Thread.join 将会使主调线程堵塞,直到被调用线程运行结束或超时。参数timeout是一个数值类型,表示超时时间,如果未提供该参数,那么主调线程将一直堵塞到被调线程结束。

Hello World

多任务可以由多进程完成,也可以由一个进程内的多线程完成。 进程是由若干线程组成的,一个进程至少有一个线程。 由于线程是操作系统直接支持的执行单元,因此,高级语言通常都内置多线程的支持,Python也不例外,并且,Python的线程是真正的Posix Thread,而不是模拟出来的线程。

POSIX线程(英语:POSIX Threads,常被缩写为Pthreads)是POSIX的线程标准,定义了创建和操纵线程的一套API。 实现POSIX 线程标准的库常被称作Pthreads,一般用于Unix-like POSIX 系统,如Linux、 Solaris。但是Microsoft Windows上的实现也存在,例如直接使用Windows API实现的第三方库pthreads-w32;而利用Windows的SFU/SUA子系统,则可以使用微软提供的一部分原生POSIX API。

Python的标准库提供了两个模块:_threadthreading_thread 是低级模块,threading 是高级模块,对 _thread 进行了封装。绝大多数情况下,我们只需要使用threading 这个高级模块。

三步创建并运行一个线程: 1、把一个函数传入并创建Thread实例
2、调用start()开始执行
3、调用join()开始阻塞主调线程,运行当前线程,之前主调线程的阻塞会在当前进程完成之后结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# coding: utf-8

import time, threading

def loop():
print("Inner thread %s is running..." % threading.current_thread().name)
n = 0
while n < 5:
n += 1
print("Inner thread %s >>> %s" % (threading.current_thread().name, n))
time.sleep(1)
print("Inner thread %s is ended..." % threading.current_thread().name)

print("Start: thread %s is running..." % threading.current_thread().name)
t = threading.Thread(target=loop, name="LoopThread") # 1、把一个函数传入并创建Thread实例
t.start() # 2、调用start()开始执行
t.join() # 3、调用join()开始阻塞主调线程,运行当前线程
print("End: thread %s is ended..." % threading.current_thread().name)

Output

1
2
3
4
5
6
7
8
9
Start: thread MainThread is running...
Inner thread LoopThread is running...
Inner thread LoopThread >>> 1
Inner thread LoopThread >>> 2
Inner thread LoopThread >>> 3
Inner thread LoopThread >>> 4
Inner thread LoopThread >>> 5
Inner thread LoopThread is ended...
End: thread MainThread is ended...

由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程,Python的 threading 模块有个 current_thread() 函数,它永远返回当前线程的实例。主线程实例的名字叫 MainThread,子线程的名字在创建时指定,我们用 LoopThread 命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1Thread-2……

Lock

多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。

来看看多个线程同时操作一个变量怎么把内容给改乱了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# coding: utf-8

import time, threading

balance = 0

def change_it(n):
global balance
balance += n
balance -= n

def run_thread(n):
for _ in range(10000000):
change_it(n)


t1 = threading.Thread(target=run_thread, args=(5, ))
t2 = threading.Thread(target=run_thread, args=(8, ))

# t1和t2并发执行 并发执行的时候 才需要锁
# 启动t1线程
t1.start()
# 启动t2线程
t2.start()
# t1争夺主线程地位
t1.join()
# t2争夺主线程地位
t2.join()

# t1和t2顺序执行 顺序执行时 不需要锁
# 启动t1线程
t1.start();
# 等待t1结束,这时候t2线程并未启动
t1.join();
# t1结束后,启动t2线程
t2.start();
# 等待t2结束
t2.join();

print(balance)

Output

1
183

我们定义了一个共享变量balance,初始值为0,并且启动两个线程,先存后取,理论上结果应该为0,但是,由于线程的调度是由操作系统决定的,当 t1t2 交替执行时,只要循环次数足够多,balance的结果就不一定是0了。

原因是因为高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算:

1
balance = balance + n

也分两步:

  1. 计算balance + n,存入临时变量中;
  2. 将临时变量的值赋给balance

也就是可以看成:

1
2
x = balance + n
balance = x

由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:

1
2
3
4
5
6
7
8
9
10
11
12
13
初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2 # balance = 0

结果 balance = 0

但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2 # balance = 8

t1: balance = x1 # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1 # balance = 0

t2: x2 = balance - 8 # x2 = 0 - 8 = -8
t2: balance = x2 # balance = -8

结果 balance = -8

究其原因,是因为修改balance需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。

两个线程同时一存一取,就可能导致余额不对,你肯定不希望你的银行存款莫名其妙地变成了负数,所以,我们必须确保一个线程在修改balance的时候,别的线程一定不能改。

如果我们要确保balance计算正确,就要给change_it()上一把锁,当某个线程开始执行change_it()时,我们说,该线程因为获得了锁,因此其他线程不能同时执行change_it(),只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁就是通过threading.Lock()来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# coding: utf-8

import time, threading

balance = 0
lock = threading.Lock() # 声明一个线程锁对象

def change_it(n):
global balance
balance += n
balance -= n

def run_thread(n):
for _ in range(10000000):
# 获得锁
lock.acquire()
try:
# 放心大胆的改吧
change_it(n)
finally:
lock.release()

t1 = threading.Thread(target=run_thread, args=(5, ))
t2 = threading.Thread(target=run_thread, args=(8, ))
t1.start()
t2.start()
t1.join()
t2.join()

print(balance)

Output

1
0

当多个线程同时执行lock.acquire()时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。

获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally来确保锁一定会被释放。

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。

Multi-Core CPU

如果你不幸拥有一个多核CPU,你肯定在想,多核应该可以同时执行多个线程。 如果写一个死循环的话,会出现什么情况呢? 打开Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以监控某个进程的CPU使用率。 我们可以监控到一个死循环线程会100%占用一个CPU。 如果有两个死循环线程,在多核CPU中,可以监控到会占用200%的CPU,也就是占用两个CPU核心。 要想把N核CPU的核心全部跑满,就必须启动N个死循环线程。 试试用Python写个死循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
import time, threading, multiprocessing

print("CPU个数{}".format(multiprocessing.cpu_count()))

def loop():
x = 0
while True:
x = x ** 1

for i in range(multiprocessing.cpu_count()):
t = threading.Thread(target=loop)
t.start()

Output

1
CPU个数8

由于双线程技术,4核CPU拥有了8线程。超线程技术的工作原理是在每个时钟周期内完成更多工作,一个支持超线程(HT)技术的处理器使当前的操作系统和应用可以看到两个虚拟处理器,该处理器可以同时处理两组任务,充分利用闲置资源,并可在相同时间内完成更多工作。

在处理多个线程的过程中,多线程处理器内部的每个逻辑处理器均可以单独对中断做出响应,当第一个逻辑处理器跟踪一个软件线程时,第二个逻辑处理器也开始对另外一个软件线程进行跟踪和处理了。另外,为了避免CPU处理资源冲突,负责处理第二个线程的那个逻辑处理器,其使用的是仅是运行第一个线程时被暂时闲置的处理单元.例如:当一个逻辑处理器在执行浮点运算(使用处理器的浮点运算单元)时,另一个逻辑处理器可以执行加法运算(使用处理器的整数运算单元)。这样做,无疑大大提高了处理器内部处理单元的利用率和相应的数据、指令的吞吐能力。

启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有38%,也就是仅使用了一个线程。 但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢? 因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁(全局解释器锁):Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。 GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。 所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。 不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

Summary

多线程编程,模型复杂,容易发生冲突,必须用锁加以隔离,同时,又要小心死锁的发生。 Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦。

如果你的代码是CPU密集型,多个线程的代码很有可能是线性执行的。所以这种情况下多线程是鸡肋,效率可能还不如单线程因为有context switch。

但是:如果你的代码是IO密集型,多线程可以明显提高效率。例如制作爬虫(我就不明白为什么Python总和爬虫联系在一起…不过也只想起来这个例子…),绝大多数时间爬虫是在等待socket返回数据,最终结果是某个线程等待IO的时候其他线程可以继续执行。

知乎yegle


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!