Python多线程详解
1 前言
本系列打算将 Python 中的线程、进程以及协程做一个全面的总结,本文目前是第一部分——多线程。在正式进入线程的讲解之前,我们先来熟悉一下相关的概念。
1.1 并发与并行
- 并发:在操作系统中,某一时间段,几个程序在同一个CPU上运行,但在任意一个时间点上,只有一个程序在CPU上运行。
- 并行:当操作系统有多个CPU时,一个CPU处理A线程,另一个CPU处理B线程,两个线程互相不抢占CPU资源,可以同时进行,这种方式成为并行。
即使是在单核CPU上,当程序运行速度足够快,虽然程序是在并发的交替执行,但是给我们带来的感受却像是在并行运行一样。Python 中有两种并发的形式—— threading 和 asyncio。其中 threading 就是我们今天要讲的多线程,而 asyncio(协程) 我们放到后面讲
1.2 进程、线程、协程
- 进程:进程是执行中的程序(QQ在没有运行的情况下是应用程序,不是进程,只有我们双击运行它的时候,它才是进程),是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
- 线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位,也被称为轻量级进程。
- 协程:
1.3 应用场景
- 并发通常应用于I/O操作频繁的场景,比如你要从网站上下载多个文件,I/O操作的时间可能会比CPU运行处理的时间长得多。
- 并行则更多应用于CPU heavy的场景,比如MapReduce中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。
2 线程介绍
2.1 什么是线程
线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位,也被称为轻量级进程。
2.2 为什么使用多线程
已经有了进程,为什么还需要线程?为了回答这个问题,我们需要再回顾一下进程的定义:进程是系统进行资源分配和调度的基本单位。系统为每个进程都分配了资源,使得进程之间不受资源的干扰,提高了计算机的运行效率。但是进程还是有缺陷的,主要有以下两点:
- 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
- 进程在执行的过程中如果阻塞,整个进程就会挂起。
相比之下,多线程编程具有以下优点:
- 同个进程下的线程共享内存,所以同进程下的多线程之间通信方便;
- 同个进程下的不同线程之间可以代价更小的切换,从而实现并发执行;
2.3 进程与线程之间的区别
- 进程是CPU资源分配的基本单位,线程是独立运行和独立调度的基本单位(CPU上真正运行的是线程)。
- 进程拥有自己的资源空间,一个进程包含若干个线程,线程与CPU资源分配无关,多个线程共享同一进程内的资源。
- 线程的调度与切换比进程快很多。
- 多进程对于多CPU,多线程对应多核CPU。
3 多线程实现
3.1 普通创建方式
import threading
import time
def run(n):
print("task", n)
time.sleep(1)
print(n + '-2s')
time.sleep(1)
print(n + '-1s')
time.sleep(1)
print(n + '-0s')
time.sleep(1)
if __name__ == '__main__':
t1 = threading.Thread(target=run, args=("t1",)) # 注意("t1",)是元组,一定要加逗号
t2 = threading.Thread(target=run, args=("t2",))
t1.start()
t2.start()
运行结果如下:
task t1
task t2
t1-2s
t2-2s
t1-1s
t2-1s
t1-0s
t2-0s
这里插入一个小知识点:线程 start() 和 run() 方法的区别(Demo可以看这里):
- start() 方法是启动一个子线程,线程名就是我们定义的name
- run() 方法并不启动一个新线程,就是在主线程中调用了一个普通函数而已
3.2 自定义线程类
import threading
import time
class MyThread(threading.Thread):
def __init__(self, n):
super(MyThread, self).__init__() # 重构run函数必须写
self.n = n
def run(self):
print("task", self.n)
time.sleep(1)
print(self.n + '-2s')
time.sleep(1)
print(self.n + '-1s')
time.sleep(1)
print(self.n + '-0s')
time.sleep(1)
if __name__ == '__main__':
t1 = MyThread("t1")
t2 = MyThread("t2")
t1.start()
t2.start()
运行结果如下:
task t1
task t2
t1-2s
t2-2s
t1-1s
t2-1s
t1-0s
t2-0s
3.3 守护线程
当其它非守护线程结束时,程序退出
在下面的代码中,我们使用 setDaemon(True)
将子线程设置为守护线程,所以当主线程结束时,子线程也会随之结束,进而整个程序退出。
import threading
import time
def run(n):
print("task" + n)
time.sleep(1)
print(n + '-2s')
time.sleep(1)
print(n + '-1s')
time.sleep(1)
print(n + '-0s')
time.sleep(1)
if __name__ == '__main__':
t1 = threading.Thread(target=run, args=("t1",)) # 注意("t1",)是元组,一定要加逗号
t2 = threading.Thread(target=run, args=("t2",))
t1.setDaemon(True)
t2.setDaemon(True)
t1.start()
t2.start()
print("Done")
代码运行结果:
taskt1
taskt2
Done
3.4 主线程等待子线程结束
为了让守护线程执行结束之后,主线程再结束,我们可以使用join方法,让主线程等待子线程执行。
import threading
import time
def run(n):
print("task" + n)
time.sleep(1)
print(n + '-2s')
time.sleep(1)
print(n + '-1s')
time.sleep(1)
print(n + '-0s')
time.sleep(1)
if __name__ == '__main__':
t1 = threading.Thread(target=run, args=("t1",)) # 注意("t1",)是元组,一定要加逗号
t2 = threading.Thread(target=run, args=("t2",))
t1.setDaemon(True)
t2.setDaemon(True)
t1.start()
t2.start()
t1.join()
t2.join()
print("Done")
代码运行结果如下:
taskt1
taskt2
t1-2s
t2-2s
t1-1s
t2-1s
t2-0s
t1-0s
Done
3.5 多线程共享全局变量
线程是进程的执行单元,进程是系统分配资源的最小单位,所以在同一个进程中的多线程是共享资源的。
import threading
import time
g_num = 0
def work(n):
global g_num
for i in range(3):
g_num += 1
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
t1 = threading.Thread(target=work, args=('1',))
t2 = threading.Thread(target=work, args=('2',))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"in main g_num is : {g_num}")
运行结果:
in work1 g_num is : 3
in work2 g_num is : 6
in main g_num is : 6
3.6 互斥锁
由于线程之间是随机调度的,而且各个线程的执行时间不一定,当多个线程同时修改同一条数据时就可能发生意想不到的情况。如下例子,当每个函数都循环1000000次时,最终的g_num大概率不等于(小于)2000000
import threading
import time
g_num = 0
def work(n):
global g_num
for i in range(1000000):
g_num += 1
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
t1 = threading.Thread(target=work, args=('1',))
t2 = threading.Thread(target=work, args=('2',))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"in main g_num is : {g_num}")
运行结果:
in work1 g_num is : 1422951
in work2 g_num is : 1496914
in main g_num is : 1496914
这是因为在计算机内部CPU执行 g_num += 1
这行代码时,会分为三个步骤执行:
- 先从内存中将
g_num
的值读取出来 - 将读取出的
g_num
值加1 - 将加1后的新值赋值给
g_num
正如我们刚刚提到的线程的是被随机调度的,而且执行的时间也不一定。假设 work1 线程此刻掌握着CPU的执行权,且此时的 g_num
值为100,当进行完前两步的读取、加1操作后,正准备将101的新值赋值给 g_num
的时候,work1 丧失了执行权,轮到 work2 执行。由于 work2 的执行时间也不一定,假设 work2 一直运行,将 g_num
的值累加到了 110 ,此时 work1 又重获CPU的执行权,那么此时直接从第三步开始执行,将 101 的值赋给 g_num
。所以最终g_num
很难累加到 2000000,当然也有一定的概率。
为了解决上述问题,引入互斥锁的概念,使用threading模块中的Lock,给可能冲突的代码段上锁、解锁。从而避免资源竞争引发的错误。如下代码所示:
import threading
import time
g_num = 0
lock = threading.Lock()
def work(n):
global g_num
for i in range(1000000):
lock.acquire()
g_num += 1
lock.release()
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
t1 = threading.Thread(target=work, args=('1',))
t2 = threading.Thread(target=work, args=('2',))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"in main g_num is : {g_num}")
运行结果如下:
in work1 g_num is : 1852599
in work2 g_num is : 2000000
in main g_num is : 2000000
可以看到,最终的 g_num
结果等于2000000,但是运行效率低了很多,而且需要注意避免死锁的产生。
3.6.1 死锁
互斥锁可能造成的问题:死锁。所谓死锁: 是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。形成死锁的两种情况:
- 锁嵌套,指当一个线程在获取临界资源时,又需要再次获取;
- 如果有多个公共资源,在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源;
- 锁嵌套形成死锁代码如下:
import threading
import time
g_num = 0
lock = threading.Lock()
def work(n):
global g_num
for i in range(1000000):
lock.acquire()
lock.acquire()
g_num += 1
lock.release()
lock.release()
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
threads = []
for i in range(10):
threads.append(threading.Thread(target=work, args=(i,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(f"in main g_num is : {g_num}")
可以看到再同一个线程中释放锁之前,又要获取锁,就会形成嵌套锁,导致程序进入死循环。
- 多个资源互相等待形成的思索代码如下:
import threading
import time
g_num = 0
lock1 = threading.Lock()
lock2 = threading.Lock()
def work1(n):
global g_num
for i in range(1000000):
# lock1上锁,并等待1s,等work2把lock2锁上
print("lock1 上锁")
lock1.acquire()
time.sleep(1)
# 这里会阻塞,因为work2已经把lock上锁
lock2.acquire()
g_num += 1
lock2.release()
lock1.release()
print(f"in work{n} g_num is : {g_num}\n")
def work2(n):
global g_num
for i in range(1000000):
# lock2上锁,并等待1s,等work1把lock1锁上
print("lock2 上锁")
lock2.acquire()
time.sleep(1)
# 这里会阻塞,因为work1已经把lock1锁上了
lock1.acquire()
g_num += 1
lock1.release()
lock2.release()
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
t1 = threading.Thread(target=work1, args=('1',))
t2 = threading.Thread(target=work2, args=('2',))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"in main g_num is : {g_num}")
上述代码运行结果:
lock1 上锁
lock2 上锁
可以看出,程序一开始 work1 先把 lock1 锁上,并在等待1秒钟后,想要继续把 lock2 上锁。而就在 work1 等待的那一秒钟里,work2 把 lock2 上锁了,并且也等着1秒钟后上锁 lock1。因此出现了“死锁”:work1 握着 lock1 对 work2 喊道:你把 lock2 先给我;而work2 攥着 lock2 对work1喊道:你先把 lock1 给我。两人谁都不肯先放手,于是乎,deap loop。
3.7 递归锁
为了解决互斥锁嵌套引起的死锁问题,我们可以使用Python 的递归锁,即 threading.RLock。RLock 内部维护着一个 Lock 和一个 counter 变量,counter 记录了 acquire 的次数,从而使得资源可以被多次 acquire。直到一个线程所有的 acquire 都被 release,其他的线程才能获得资源。
递归锁与互斥锁的不同之处在于,我们可以同时调用多次 lock.acquire() 对互斥资源进行加锁,而互斥锁却不可以,如果互斥锁多次进行加锁,则会导致死锁。
注意,如果是由于多个资源互相等待对方释放锁而引起的死锁,递归锁是无法解决的,只能通过程序设计的方法避免。
下面,我们改造3.6.1中由于嵌套而形成死锁的代码:
import threading
import time
g_num = 0
lock = threading.RLock()
def work(n):
global g_num
for i in range(1000000):
lock.acquire()
lock.acquire()
g_num += 1
lock.release()
lock.release()
print(f"in work{n} g_num is : {g_num}\n")
if __name__ == '__main__':
threads = []
for i in range(10):
threads.append(threading.Thread(target=work, args=(i,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(f"in main g_num is : {g_num}")
可以看到,我们只是将原来的 lock = threading.Lock()
换成 lock = threading.RLock()
,便解决了问题。
3.8 信号量
5 多线程实战
5.1 初探多线程编程
我们写一个简单的爬虫程序,分别用单线程和多线程的形式来实现,让大家直观感受一下多线程带来的效率的提升。
- 首先,我们编写 blog_spider.py 文件,来简单爬取某网站新闻页
import requests
from bs4 import BeautifulSoup
urls = [f"https://news.cnblogs.com/n/page/{n}/" for n in range(1, 51)]
def craw(url):
res = requests.get(url)
return res.text
def parse(html):
soup = BeautifulSoup(html, "html.parser")
titles = soup.find_all("h2", class_="news_entry")
return [title.get_text() for title in titles]
if __name__ == "__main__":
for url in urls:
print(url)
html = craw(url)
parse(html)
- 其次,我们编写 threading-craw01.py 文件,在该文件中分别用单线程和多线程形式对目标地址进行爬取,并利用分别计算其运行时间
import threading
import time
import MyThreading.blog_spider
def single_thread():
print("single_thread begin")
for url in MyThreading.blog_spider.urls:
MyThreading.blog_spider.craw(url)
print("single_thread end")
def multi_thread():
print("multi_thread begin")
threads = []
for url in MyThreading.blog_spider.urls:
threads.append(threading.Thread(target=MyThreading.blog_spider.craw, args=(url,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("multi_thread end")
if __name__ == "__main__":
start = time.time()
single_thread()
end = time.time()
print("single thread cost:", end - start, "seconds")
start = time.time()
multi_thread()
end = time.time()
print("multi thread cost:", end - start, "seconds")
最后运行结果如下:
single_thread begin
single_thread end
single thread cost: 5.048862934112549 seconds
multi_thread begin
multi_thread end
multi thread cost: 0.6458339691162109 seconds
从最终的运行结果可以看出,使用多线程后,运行效率提高了近10倍!