Python中的线程学习笔记
文章目录
一级目录
二级目录
三级目录
1.同步任务与并发任务
1.同步任务:
import time
start = time.time()
def work1():
print('work1')
time.sleep(2)
def work2():
print('work2')
time.sleep(2)
work1()
work2()
final = time.time()
print('总耗时:', final - start)
很明显的是,python按顺序读取代码行,先调用work1
函数,然后再调用work2
函数,最后输出结果为:
总耗时: 4.001288414001465
2.并发任务:
与上代码相似:
import threading
import time
start = time.time()
def work1():
print('work1')
time.sleep(2)
def work2():
print('work2')
time.sleep(2)
if __name__ == '__main__':
t1 = threading.Thread(target=work1) # thread是一个类,所以thread()是一个对象,t2同理,t1.start()表示开始,join()表示主线程等待
t2 = threading.Thread(target=work2)
t1.start()
t2.start()
t1.join()
t2.join()
final = time.time()
print('总耗时:', final - start)
最后结果为:
总耗时: 2.0015580654144287
当然,这里我们讨论的并发任务只是讲线程(本节学的是线程嘛),在这里我们导入了一个threading.py
文件。按住‘ctr
l’键点开threading.py
文件我们找到Thread类,接着我们创建了Thread类的两个实例对象t1,t2
,target是Thread类中__init__
所定义的,默认值为None。target=work1
的意思是创建一个新线程,当这个新线程启动(t1.start()
)后,它会去运行 work1()
函数。这里需要敲黑板划重点的是,target
应该是一个函数名,而不是函数调用(也就是说,你应该传入 work1
,而不是 work1()
)。如果需要为 target
指定的函数传递参数,你可以通过 args
参数来实现,例如 t1 = threading.Thread(target=work1, args=(arg1, arg2))
,这样就可以把 arg1
和 arg2
作为参数传递给 work1
函数。大家可以自己去读源码大胆调试。
2.线程与进程:
我们在日常开发中经常会听到使用多线程/多进程的方式完成并发任务。那么什么是进程?什么是线程?进程与线程之间有什么关系?接下来我们通过日常场景简单的了解一下进程与线程。
一个工厂,至少有一个车间,一个车间中最少有一个工人,最终是工人在工作。
一个程序,至少有一个进程,一个进程中最少有一个线程,最终是线程在工作。
在一个车间中有最基本的工作工具,人通过操作工具的方式来完成工作。
一个进程中有最基本的运行代码的资源(内存、cpu…),线程通过进程中提供的资源来运行代码。
通过上述描述,我们来总结一下线程与进程的关系:
- 线程是计算机可以被
cpu
调度的最小单元 - 进程是计算机分配资源的的最小单元,进程可以为线程提供运行资源。
一个进程中可以有多个线程,同一个进程中的线程可以共享当前进程中的资源。
3.使用线程来实现简单的网页爬虫
import requests
url_list =['https://img2.baidu.com/it/u=3377912230,2579580553&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img0.baidu.com/it/u=1286280662,771625426&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=666',
'https://img0.baidu.com/it/u=3658085279,4130126488&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500',
'https://img2.baidu.com/it/u=2151923668,680993362&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=751',
'https://img2.baidu.com/it/u=1142184655,1105324174&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img2.baidu.com/it/u=758203457,3028326283&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img0.baidu.com/it/u=1459006830,3464289992&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img2.baidu.com/it/u=706136425,3304130816&fm=253&fmt=auto&app=120&f=JPEG?w=1422&h=800',
'https://img2.baidu.com/it/u=3305065846,2644226423&fm=253&fmt=auto&app=138&f=JPEG?w=640&h=427',
'https://img1.baidu.com/it/u=3134263046,3725943906&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500',
'https://img1.baidu.com/it/u=1663695725,3595680051&fm=253&fmt=auto&app=138&f=JPEG?w=521&h=346',
'https://img1.baidu.com/it/u=484835970,2055334396&fm=253&fmt=auto&app=120&f=JPEG?w=1067&h=800',
'https://img0.baidu.com/it/u=1926481352,4285131714&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=552',
'https://img2.baidu.com/it/u=2361006955,1078966559&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=333',
'https://img0.baidu.com/it/u=1999287725,2012674562&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
]
def get_image(image_url, file_name):
response = requests.get(image_url).content
with open('images/' + file_name + '.png', mode='wb') as f:
f.write(response)
print('正在下载:', file_name)
if __name__ == '__main__':
name = 1
for url in url_list:
t = threading.Thread(target=get_image, args=(url, str(name))) #str(name)进行强转为字符串
t.start()
name += 1
这里我们会发现用这种方法比同步请求下载的速度快很多!所以我们可以得出结论:在在I/O密集型操作中,如文件操作、网络请求等,线程的优势更为明显。
4.GIL锁
:
全局解释器锁(Global Interpreter Lock, GIL)是 Python 解释器设计中的一个关键概念。它是 Python 的 C实现(也就是 CPython)在设计时为了解决线程安全问题而引入的一个策略。
具体的细节可以参考这篇文章:https://zhuanlan.zhihu.com/p/75780308
简单来说,Python 中的所有线程,不管系统有多少 CPU 核心,同一时刻,只能有一个线程执行 Python 字节码,其他线程都处在等待状态。这个锁,就是只允许同时有一个线程执行的“全局解释器锁”。但是,很坑的是,GIL锁并不能保证操作的原子性。请看一下代码:
import threading
n = 0
def add():
global n
for i in range(1000000):
n = n + 1
def sub():
global n
for i in range(1000000):
n = n - 1
if __name__ == "__main__":
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=sub)
t1.start()
t2.start()
t1.join()
t2.join()
print("n的值为:", n)
在python3.9版本中测试出来的结果往往各不相同,均不为0。这是为什么?举个例子,n = 0这个操作是原子性操作,而n +=1 这个操作就不是原子性操作,因为第一步,首先要获取n的值;第二步,n+1;第三步,把n+1的值赋值给n。那么在这三步操作过程中随时都有可能被t2线程打断,从而无法实现n+=1的效果,所以最后结果不是0,至于答案则是要看操作系统l了。要确保答案是0,我们需要使用互斥锁,后面会讲到。
5.线程方法:
以下是常见的线程方法:
threading.Thread(target, args, kwargs, name): 这是 Thread 类的构造函数,用于创建新的线程。target 是线程打算调用的函数,args 是调用目标的位置参数列表,kwargs 是调用目标的关键字参数字典,name 是线程名。
start(): 用于启动一个线程。
join(timeout): 阻塞当前上下文线程,直到调用此方法的线程终止或直到指定的 timeout。
is_alive(): 返回线程是否活动。一个启动且尚未停止的线程是活动的。
getName(): 返回线程名。这个方法已在 Python 3.10 中被弃用,我们应直接访问 name 属性来获取线程名称。
setName(): 设置线程名。这个方法已在 Python 3.10 中被弃用,我们应直接访问 name 属性来设置线程名称。
此外还有一些 threading 模块级别的函数:
threading.active_count(): 返回当前活动的 Thread 对象个数。活动的线程包括主线程和已经启动但未停止的非守护线程。
threading.current_thread(): 返回当前对应的 Thread 对象,对应当前线程。
threading.get_ident(): 返回当前线程的“线程标识符”,这是一个非零整数。
举个例子;
import threading
def work():
name = threading.current_thread().name
print(name)
for i in range(5):
t = threading.Thread(target=work)
t.name = f'线程: {i}'
t.start()
print('\n'+threading.main_thread().name)
# mian_thread and current_thread are similar,they are both classes.
结果如下:
线程: 0
线程: 1
线程: 2
线程: 3
线程: 4
MainThread
这里要说明的是主线程在每一次遇见 t = threading.Thread(target=work)的时候都会创建一个子线程,可以理解为细胞分裂,当然了子线程也可以创建子线程,只是在这里我们的代码没有体现而已啦。在python中默认的主线程名称是MainThread,但应注意的是,我们可以通过 threading.Thread
类的 name
参数自定义线程的名称,包括主线程。只是在实际编程中,我们通常不改变主线程的名称。
6.使用面向对象的方式运行线程
在Python中,我们可以使用面向对象的方式来创建和管理线程。这通常通过继承Python内置模块threading
中的Thread
类来实现,然后在子类中重写run()
方法。以下是代码示例:
import requests
import threading # A_PY_FILE_WAS_IMPORTED
class ImageDownload(threading.Thread):
def __init__(self, url, file_name):
super().__init__()
self.url = url
self.file_name = file_name
# 重写
def run(self):
response = requests.get(self.url).content
with open('images/' + str(self.file_name) + '.png', mode='wb') as f:
f.write(response)
print('正在下载:', str(self.file_name))
url_list = [
'https://img2.baidu.com/it/u=3377912230,2579580553&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img0.baidu.com/it/u=1286280662,771625426&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=666',
'https://img0.baidu.com/it/u=3658085279,4130126488&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500',
'https://img2.baidu.com/it/u=2151923668,680993362&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=751',
'https://img2.baidu.com/it/u=1142184655,1105324174&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img2.baidu.com/it/u=758203457,3028326283&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img0.baidu.com/it/u=1459006830,3464289992&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img2.baidu.com/it/u=706136425,3304130816&fm=253&fmt=auto&app=120&f=JPEG?w=1422&h=800',
'https://img2.baidu.com/it/u=3305065846,2644226423&fm=253&fmt=auto&app=138&f=JPEG?w=640&h=427',
'https://img1.baidu.com/it/u=3134263046,3725943906&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500',
'https://img1.baidu.com/it/u=1663695725,3595680051&fm=253&fmt=auto&app=138&f=JPEG?w=521&h=346',
'https://img1.baidu.com/it/u=484835970,2055334396&fm=253&fmt=auto&app=120&f=JPEG?w=1067&h=800',
'https://img0.baidu.com/it/u=1926481352,4285131714&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=552',
'https://img2.baidu.com/it/u=2361006955,1078966559&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=333',
'https://img0.baidu.com/it/u=1999287725,2012674562&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
]
filename = 1
for url in url_list:
t = ImageDownload(url, filename)
t.start()
filename += 1
那么问题来了,为什么我们明明没有调用run()方法,只是调用了star()方法,那么是为什么会执行run()方法呢?这是因为start()
方法会在内部调用run()
方法来执行线程的任务。这是Python线程实现的明确设计。当你使用threading.Thread
创建线程对象并调用start()
方法时,Python会为你启动一个新的操作系统线程,然后在这个新线程中调用你的run()
方法。这就是为什么你明明只调用了start()
方法,但实际上却执行了run()
方法。run()
方法是线程要执行的任务,而start()
方法是启动线程的指令。
7.互斥锁
互斥锁也可以称作线程锁,主要包含两种锁:1.同步互斥锁,2.递归互斥锁
1.同步互斥锁:
互斥同步锁(或称为互斥锁,Mutex)是一种用于解决多线程资源竞争的经典同步手段。互斥简单地说就是一次只允许一个线程访问某一共享的数据资源。
互斥锁最基本的两个操作就是“加锁(Lock)”和“解锁(Unlock)”。当一个线程要访问共享资源时,它必须首先尝试去“加锁”,如果锁已经被其他线程加上,那该线程就会被阻塞,直至锁被释放;如果锁是未被加上的状态,则该线程可以成功加锁并继续执行。当这个线程完成对共享资源的访问后,它必须“解锁”以便让其他在等待的线程可以有机会获取锁。
这样,互斥同步锁就可以保证多线程环境中,对于同一共享资源的访问保持同步和互斥,避免了因为多重线程“同时操作”共享资源而产生的数据不一致性问题。
from threading import Thread
from threading import Lock
lock = Lock()
n = 0
def add():
global n
for _ in range(1000000):
lock.acquire()
n += 1
lock.release()
def sub():
global n
for _ in range(1000000):
lock.acquire()
n -= 1
lock.release()
t1 = Thread(target=add)
t2 = Thread(target=sub)
t1.start()
t2.start()
t1.join()
t2.join()
print(n)
和GIL锁那部分相同的代码,只是不同的是在这里的结果始终都是0,因为有了互斥锁的加持。互斥锁的加持确保了n+=1或者n-=1操作的原子性。为了避免数据一致性问题(比如说在两个线程同时对n
进行写入操作),代码中显式地在修改n
之前获取了锁(lock.acquire()
),同时在修改完成后释放了锁(lock.release()
)。
2.递归互斥锁:
递归互斥锁,或者叫可重入互斥锁(Reentrant Mutex),通常用于解决以下几种类型问题:
- 当一个已经获取了互斥锁的线程在没有释放该锁的情况下再次尝试获取同一个互斥锁,传统的互斥锁就会阻塞该线程,从而导致死锁。递归互斥锁在这种情况下可以正确处理,不会引起线程阻塞。
- 当一个线程需要多次进入一个已经由自己获取锁的临界区时,可以使用递归互斥锁。
递归互斥锁内部通常会维护一个计数器和一个所有者线程ID,当线程获得锁时,计数器加一;当线程释放锁时,计数器减一。只有当计数器值为0时,锁才算是完全释放,这时其他线程才能够获取该锁。
在Python中,threading模块提供了递归锁RLock类,你可以像使用普通锁Lock类一样使用RLock。RLock的释放只能由锁的持有者进行,否则会引发异常。
这是递归互斥锁的简单例子:
from threading import Thread
from threading import RLock
lock1 = RLock()
n = 0
def add():
global n
for _ in range(1000000):
lock1.acquire()
lock1.acquire()
n += 1
lock1.release()
lock1.release()
def sub():
global n
for _ in range(1000000):
lock1.acquire()
lock1.acquire()
n -= 1
lock1.release()
lock1.release()
t1 = Thread(target=add)
t2 = Thread(target=sub)
t1.start()
t2.start()
t1.join()
t2.join()
print(n)
3.使用互斥锁需要注意的问题:
死锁(Deadlock):这是最常见的问题,发生在两个或更多线程永久地阻塞对方,并等待对方释放锁。这通常发生在每个线程同时持有一个锁并请求获取另一个锁的情况下。要避免死锁,可以使用一些策略,比如避免嵌套锁,或者总是以相同的顺序请求锁。
出现死锁的三种情况:1.同步锁上锁两次2.锁对象没有释放3.在全局中创建了两个锁对象,并且任务一上锁锁对象1,释放锁对象2,任务二上锁锁对象2,释放锁对象1
import threading
mutexA = threading.Lock()
mutexB = threading.Lock()
class MyThread1(threading.Thread):
def run(self):
# 对mutexA上锁
mutexA.acquire()
# mutexA上锁后,延时1秒,等待另外那个线程 把mutexB上锁
print(self.name + '----do1---up----')
time.sleep(1)
# 此时会堵塞,因为这个mutexB已经被另外的线程抢先上锁了
mutexB.acquire()
print(self.name + '----do1---down----')
mutexB.release()
# 对mutexA解锁
mutexA.release()
class MyThread2(threading.Thread):
def run(self):
# 对mutexB上锁
mutexB.acquire()
# mutexB上锁后,延时1秒,等待另外那个线程 把mutexA上锁
print(self.name + '----do2---up----')
time.sleep(1)
# 此时会堵塞,因为这个mutexA已经被另外的线程抢先上锁了
mutexA.acquire()
print(self.name + '----do2---down----')
mutexA.release()
# 对mutexB解锁
mutexB.release()
if __name__ == '__main__':
t1 = MyThread1()
t2 = MyThread2()
t1.start()
t2.start()
这段代码出现的问题便是对应的情况三。在这个场景下,MyThread1
和MyThread2
线程都会获取到各自的第一个锁,但在尝试获取第二个锁时,由于锁已经被对方线程获取,所以会被阻塞,从而使得两个线程都无法进行下去,造成了死锁。
解决方法有:
- 避免嵌套锁:尽可能不要在持有一个锁的情况下去获取另一个锁。
- 锁排序:如果必须获取多个锁,可以预先对这些锁进行排序,然后按照固定的顺序获取锁。
- 使用锁超时:设置一个获取锁的超时时间,超过这个时间则放弃获取锁。
总的来说,我们在使用锁的过程中需要做到锁对象:统一,全局,只有一个!
8.线程池:
1.实现线程复用
敲黑板划重点,线程池是重点和难点。在后续的学习中,我们对它有很大的应用。
线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。线程池通常由线程数量的上限和下限以及一个队列组成。队列用于存放等待执行的任务。
当有新任务到来时,如果线程池中有空闲的线程,就立即执行;如果没有,则看看线程池中的线程数是否已经达到上限,如果未达到则创建新线程来执行任务,否则就让任务排队等候。
线程池较普通线程创建的主要优点是:
- 提高性能:线程创建和销毁都需要系统资源,甚至会引起系统压力过大。当需要大量并发处理任务,且每个任务执行时间较短时,使用线程池可以显著提高性能。线程池复用已创建的线程,降低线程创建和销毁带来的开销。
- 资源限制:当有大量并发任务要运行时,一次性创建大量线程会消耗系统资源(如内存)。与此不同,线程池在初始化时设定线程的最大数量,可以在资源限制的情况下更好地进行任务调度。
- 管理方便:线程池提供了一种统一的管理方式,可以监控线程的状态和运行情况,当线程执行完毕后,可以通过线程池进行统一的回收处理。
- 更好地控制并发度:不受线程生命周期的影响,可以很好地控制系统的并发线程数,防止服务器因并发线程数过多而崩溃。
- 提供其他高级功能:线程池通常会提供一些比普通线程更高级的功能,例如延时执行、定时执行、任务优先级等。
from concurrent.futures import ThreadPoolExecutor
def get_image(image_url, file_name):
response = requests.get(image_url).content
with open('images/' + str(file_name) + '.png', mode='wb') as f:
f.write(response)
print('正在下载:', str(file_name))
url_list = [
'https://img2.baidu.com/it/u=3377912230,2579580553&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img0.baidu.com/it/u=1286280662,771625426&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=666',
'https://img0.baidu.com/it/u=3658085279,4130126488&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500',
'https://img2.baidu.com/it/u=2151923668,680993362&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=751',
'https://img2.baidu.com/it/u=1142184655,1105324174&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img2.baidu.com/it/u=758203457,3028326283&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
'https://img0.baidu.com/it/u=1459006830,3464289992&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800',
'https://img2.baidu.com/it/u=706136425,3304130816&fm=253&fmt=auto&app=120&f=JPEG?w=1422&h=800',
'https://img2.baidu.com/it/u=3305065846,2644226423&fm=253&fmt=auto&app=138&f=JPEG?w=640&h=427',
'https://img1.baidu.com/it/u=3134263046,3725943906&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500',
'https://img1.baidu.com/it/u=1663695725,3595680051&fm=253&fmt=auto&app=138&f=JPEG?w=521&h=346',
'https://img1.baidu.com/it/u=484835970,2055334396&fm=253&fmt=auto&app=120&f=JPEG?w=1067&h=800',
'https://img0.baidu.com/it/u=1926481352,4285131714&fm=253&fmt=auto&app=138&f=JPEG?w=400&h=552',
'https://img2.baidu.com/it/u=2361006955,1078966559&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=333',
'https://img0.baidu.com/it/u=1999287725,2012674562&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=889',
]
with ThreadPoolExecutor(max_workers=5) as pool: # 使用上下文管理器的方式来创建线程池对象
#方便资源管理和异常文件处理
file_name = 1
for url in url_list:
pool.submit(get_image, url, file_name)
file_name += 1
这里我们把一次性线程并发最大的数量设为5,操作系统会在url_list中随机选择5个url,然后只要一有其中一个网页加载请求完成,就会从剩下10个url中随机选择一个url进入空闲的线程。
2.线程池的创建和常用方法
1.创建线程池:
创建线程池主要用到ThreadPoolExecutor
类的构造函数,可接受的参数如下:
python
concurrent.futures.ThreadPoolExecutor(max_workers=None, thread_name_prefix='', initializer=None, initargs=())
其中:
max_workers
参数定义了线程池中的最大线程数。如果没有给出,则会默认使用min(32, os.cpu_count() + 4)
计算得出(Python3.8以上)。thread_name_prefix
可用于为创建的线程命名,方便进行调试和跟踪。initializer
是一个可调用对象,每当启动一个工作线程时,都会调用这个函数,并传入initargs
作为参数。
2.线程池的常用方法:
submit(fn, *args, **kwargs)
:将一个任务提交给线程池,此任务会调用fn(*args, **kwargs)
。返回值是一个Future
对象,代表这个任务的执行情况和结果。map(func, *iterables, timeout=None, chunksize=1)
:用法类似于Python内置的map()
函数,可以将多个任务以并行的方式提交给线程池处理。shutdown(wait=True)
:关闭线程池,阻止新任务的提交。如果wait
参数为True(默认),则会等待所有任务完成后再关闭线程池。result(timeout=None)
方法被用于获取Future表示的操作返回的结果。- 如果调用时该操作已经完成,或者在等待过程中操作完成,则该方法将返回操作的结果值。
- 若操作运行出错,则该方法将抛出该操作所抛出的异常。
- 若
timeout
被指定且操作在指定的时间内未完成,则会抛出concurrent.futures.TimeoutError
异常。 - 若操作被取消,则调用
result()
将抛出concurrent.futures.CancelledError
异常。
cancel()
方法用于取消该Future对应的未执行的操作。- 如果操作已经完成或正在执行,则无法取消,
cancel()
会返回False
。 - 如果操作尚未开始执行,并且成功的取消了,则
cancel()
会返回True
。 - 需要注意的是:如果任务进入线程池中,任务就无法取消。
- 如果操作已经完成或正在执行,则无法取消,
请注意,ThreadPoolExecutor
类也是一个上下文管理器,因此通常我们会推荐使用with
语句创建和管理线程池,这样在完成所有任务后可以自动关闭线程池,避免资源泄露。
3.as_completetd
import time
from concurrent.futures import ThreadPoolExecutor,as_completed
def get_html(time_attr):
time.sleep(time_attr)
# print('获取网站页数成功:',time_attr)
return time_attr
time_list = [1,1,10,1]
pool = ThreadPoolExecutor(max_workers=4)
future_list = [pool.submit(get_html, time_attr) for time_attr in time_list]
for future in future_list:
print(future.result())
import time
from concurrent.futures import ThreadPoolExecutor,as_completed
def get_html(time_attr):
time.sleep(time_attr)
# print('获取网站页数成功:',time_attr)
return time_attr
time_list = [1,1,10,1]
pool = ThreadPoolExecutor(max_workers=4)
future_list = [pool.submit(get_html, time_attr) for time_attr in time_list]
for future in as_completed(future_list):
print(future.result())
我们可以看到,第二块代码使用了as_completed
,通过代码测定我们可以发现:如果有一个任务需要10秒,其他任务只需要1秒。如果你不使用as_completed(),那么可能需要等待10秒才能看到任何结果。但是如果使用了as_completed(),那么在第一个1秒任务完成后就可以立刻处理并显示结果。
总结;
默认情况下,当你提交了一系列任务后,任务的结果会按照被提交的顺序返回,即使有些任务早已经完成。这可能导致你需要等待完成时间较长的任务,才能获取到其他已经完成的任务的结果。
as_completed()方法的返回值是一个迭代器,你可以遍历这个迭代器来获取任务的结果。这些结果会按照任务完成的顺序(而不是任务提交的顺序)返回。也就是说,一旦有任务完成,你就可以立即获取到该任务的结果,无论该任务在提交顺序中的位置如何。
任务执行的顺序和结果返回的顺序在并发编程中是两个相关但不完全一致的概念。
具体来说:
任务执行的顺序:这是由操作系统调度决定的。当我们在Python中使用诸如concurrent.futures的线程池或进程池时,任务(也就是函数执行或方法调用)在被提交时会转变为独立的线程或进程。然后,这些线程或进程被添加到操作系统的调度器中,调度器根据其自身的算法(这超出了Python的控制范围)选择处理器资源分配给哪个线程或进程。
结果返回的顺序:这取决于你选择获取结果的方法。如果你直接从Future对象中用result()方法获取结果,这会按照任务被提交的顺序返回结果,即等待所有任务按照提交的先后顺序完成。但是,如果你使用concurrent.futures.as_completed()方法,结果会按照任务完成的顺序返回,这意味着完成的任务的结果会被优先获得,与任务提交的顺序无关。
4. with ThreadPoolExecutor() as executor:
在下面我们给出两段代码块来做比较:
import time
from concurrent.futures import wait, ThreadPoolExecutor, as_completed
def get_num(num):
time.sleep(num)
print(f"sub_thread{num}")
return num
num_list = [3, 1, 4, 2]
executor = ThreadPoolExecutor(max_workers=4)
future_list = [executor.submit(get_num, num) for num in num_list]
print("main_thread")
在这里的输出结果为:
D:\Anaconda\envs\myenv\python.exe D:\pythonProject\核心编程\05线程\11.wait.py
main_thread
sub_thread1
sub_thread2
sub_thread3
sub_thread4
可以看到这里的主线程并不会等待子线程输出sub_thread,而是第一个输出main_thread。但是使用了with ThreadPoolExecutor() as executor
上下文管理器之后,情况似乎就不同了。。。
import time
from concurrent.futures import wait, ThreadPoolExecutor, as_completed
def get_num(num):
time.sleep(num)
print(f"sub_thread{num}")
return num
num_list = [3, 1, 4, 2]
with ThreadPoolExecutor(max_workers=4) as executor:
future_list = [executor.submit(get_num, num) for num in num_list]
print("main_thread")
这里的输出结果是:
D:\Anaconda\envs\myenv\python.exe D:\pythonProject\核心编程\05线程\11.wait.py
sub_thread1
sub_thread2
sub_thread3
sub_thread4
main_thread
首先打印的是子线程的返回值,最后再打印主线程。由此我们对比发现:
with ThreadPoolExecutor(max_workers=2) as executor: 这种形式创建的线程池(也叫上下文管理器)会在退出其内部的代码块时自动调用 executor.shutdown(wait=True) 。这个方法会阻塞,直到所有你提交给线程池的任务都完成,才会允许程序继续执行。
现在我们把as_completed
加进来:
import time
from concurrent.futures import wait, ThreadPoolExecutor, as_completed
def get_num(num):
time.sleep(num)
print(f"sub_thread{num}")
return num
num_list = [3, 1, 4, 2]
executor = ThreadPoolExecutor(max_workers=4)
future_list = [executor.submit(get_num, num) for num in num_list]
for future in as_completed(future_list):
print(future.result())
print("main_thread")
输出结果:
D:\Anaconda\envs\myenv\python.exe D:\pythonProject\核心编程\05线程\11.wait.py
sub_thread1
1
sub_thread2
2
sub_thread3
3
sub_thread4
4
main_thread