当前位置: 代码网 > it编程>前端脚本>Python > Python多线程与多进程怎么选?详解GIL锁与多核CPU利用

Python多线程与多进程怎么选?详解GIL锁与多核CPU利用

2026年08月06日 Python 我要评论
进程(process)和线程(thread)现代操作系统(mac os x,unix,linux,windows)都是支持“多任务”的操作系统“多任务”

进程(process)和线程(thread)

现代操作系统(mac os x,unix,linux,windows)都是支持“多任务”的操作系统

“多任务”:操作系统可以同时运行多个任务 → \rightarrow →打个比方,你一边在用浏览器上网,一边在听mp3,一边在用word赶作业,至少同时有3个任务正在运行

单核cpu执行多任务的方式:操作系统轮流让各个任务交替执行 → \to →任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去

只能在多核cpu上实现「真正的并行执行多任务」

对于操作系统而言,一个任务就是一个进程(process) → \rightarrow →比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就是启动一个记事本进程

有些进程还不止同时干一件事,比如word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,进程内的这些“子任务”被称为线程(thread)

由于每个进程至少要干一件事,所以,一个进程至少有一个线程

线程是最小的执行单元,而进程由至少一个线程组成

在python中,多任务的实现方式:多进程模式;多线程模式;多进程+多线程模式

多进程

fork():unix/linux操作系统中的系统调用,不同于普通函数的一次调用返回一次,fork()的一次调用将返回两次 → \rightarrow 原因:操作系统自动把当前父进程复制一份为子进程,然后分别在父进程和子进程内返回 → \to →子进程永远返回0,而父进程返回子进程的id

在python的父进程中创建子进程

import os

print('process (%s) start...' % os.getpid())
# only works on unix/linux/macos:
pid = os.fork()
if pid == 0:
    print('i am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
    print('i (%s) just created a child process (%s).' % (os.getpid(), pid))

运行结果

process (876) start...
i (876) just created a child process (877).
i am child process (877) and my parent is 876.

在unix/linux操作系统中,通过fork()调用,一个进程在接到新任务时就可以复制出一个子进程来处理新任务

multiprocessing

multiprocessing模块通过提供process类来表示一个进程对象

from multiprocessing import process
import os

# 子进程要执行的代码
def run_proc(name):
    print('run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
    print('parent process %s.' % os.getpid())
    p = process(target=run_proc, args=('test',))
    print('child process will start.')
    p.start()
    p.join()
    print('child process end.')

执行结果

parent process 928.
child process will start.
run child process test (929)...
process end.
  • start():启动进程
  • join():等待子进程结束后再继续往下运行

pool

可以通过进程池的方式启动大量的子进程

from multiprocessing import pool
import os, time, random

def long_time_task(name):
    print('run task %s (%s)...' % (name, os.getpid()))
    start = time.time()
    time.sleep(random.random() * 3)
    end = time.time()
    print('task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
    print('parent process %s.' % os.getpid())
    p = pool(4)
    for i in range(5):
        p.apply_async(long_time_task, args=(i,))
    print('waiting for all subprocesses done...')
    p.close()
    p.join()
    print('all subprocesses done.')

执行结果

parent process 669.
waiting for all subprocesses done...
run task 0 (671)...
run task 1 (672)...
run task 2 (673)...
run task 3 (674)...
task 2 runs 0.14 seconds.
run task 4 (673)...
task 1 runs 0.27 seconds.
task 3 runs 0.86 seconds.
task 0 runs 1.41 seconds.
task 4 runs 1.91 seconds.
all subprocesses done.
  • apply_async():异步提交任务,任务提交后,主进程可以立即继续执行,不会阻塞等待结果
  • apply():同步提交任务,必须等函数执行完成才能继续下一步
  • close():调用之后不能继续添加新的process
  • join():等待所有子进程执行完毕

子进程

在外部进程作为子进程时,需要控制子进程的输入和输出

subprocess模块:启动一个子进程,然后控制其输入和输出

在python代码中运行命令nslookup www.python.org,这和命令行直接运行的效果是一样的

import subprocess

print('$ nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('exit code:', r)
  • nslookup(name server lookup):用于查询 dns(域名系统) 信息

运行结果

$ nslookup www.python.org
server:		192.168.19.4
address:	192.168.19.4#53

non-authoritative answer:
www.python.org	canonical name = python.map.fastly.net.
name:	python.map.fastly.net
address: 199.27.79.223

exit code: 0

如果子进程还需要输入,则可以通过communicate()方法输入

import subprocess

print('$ nslookup')
p = subprocess.popen(['nslookup'], stdin=subprocess.pipe, stdout=subprocess.pipe, stderr=subprocess.pipe)
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('utf-8'))
print('exit code:', p.returncode)
  • communicate():向子进程 stdin 写入字节串 → \to 等待子进程执行完成 → \to 返回输出和错误信息

上面的代码相当于在命令行执行命令nslookup,然后手动输入

set q=mx       # 设置查询类型为 mx(邮件服务器)
python.org     # 查询 python.org 域名
exit           # 退出 nslookup

运行结果

$ nslookup
server:		192.168.19.4
address:	192.168.19.4#53

non-authoritative answer:
python.org	mail exchanger = 50 mail.python.org.

authoritative answers can be found from:
mail.python.org	internet address = 82.94.164.166
mail.python.org	has aaaa address 2001:888:2000:d::a6


exit code: 0

进程间通信

multiprocessing模块提供了queue、pipes等多种方式来交换数据

以queue为例,在父进程中创建两个子进程,一个往queue里写数据,一个从queue里读数据

from multiprocessing import process, queue
import os, time, random

# 写数据进程执行的代码:
def write(q):
    print('process to write: %s' % os.getpid())
    for value in ['a', 'b', 'c']:
        print('put %s to queue...' % value)
        q.put(value)
        time.sleep(random.random())

# 读数据进程执行的代码:
def read(q):
    print('process to read: %s' % os.getpid())
    while true:
        value = q.get(true)
        print('get %s from queue.' % value)

if __name__=='__main__':
    # 父进程创建queue,并传给各个子进程:
    q = queue()
    pw = process(target=write, args=(q,))
    pr = process(target=read, args=(q,))
    # 启动子进程pw,写入:
    pw.start()
    # 启动子进程pr,读取:
    pr.start()
    # 等待pw结束:
    pw.join()
    # pr进程里是死循环,无法等待其结束,只能强行终止:
    pr.terminate()

运行结果

process to write: 50563
put a to queue...
process to read: 50564
get a from queue.
put b to queue...
get b from queue.
put c to queue...
get c from queue.

多线程

多任务可以由多进程完成,也可以由一个进程内的多线程完成

进程是由若干线程组成的,一个进程至少有一个线程

python的标准库提供了两个模块:_threadthreading_thread是低级模块,threading是高级模块,对_thread进行了封装

import time, threading

# 新线程执行的代码:
def loop():
    print('thread %s is running...' % threading.current_thread().name)
    n = 0
    while n < 5:
        n = n + 1
        print('thread %s >>> %s' % (threading.current_thread().name, n))
        time.sleep(1)
    print('thread %s ended.' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.thread(target=loop, name='loopthread')
t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

执行结果

thread mainthread is running...
thread loopthread is running...
thread loopthread >>> 1
thread loopthread >>> 2
thread loopthread >>> 3
thread loopthread >>> 4
thread loopthread >>> 5
thread loopthread ended.
thread mainthread ended.

由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程

lock

多线程和多进程

  • 多进程:同一个变量,各自有一份拷贝存在于每个进程中,互不影响
  • 多线程:所有变量都由所有线程共享,任何一个变量都可以被任何一个线程修改

线程共享数据的最大风险在于,当多个线程同时修改同一个变量时,可能会导致数据被破坏或出现错误

# multithread
import time, threading

# 假定这是你的银行存款:
balance = 0

def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    balance = balance + n
    balance = balance - n

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

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)

我们先定义了一个共享变量 balance,初始值设为 0。然后启动两个线程:一个负责存入,一个负责取出。按理说,最终结果应该仍然是 0。然而,由于线程调度由操作系统控制,当线程 t1t2 交替执行时,如果循环次数足够多,balance 的最终结果就可能偏离 0

高级语言中的一条语句在 cpu 中往往需要分解为多条指令来执行

balance = balance + n

也分两步:

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

由于x是局部变量,两个线程各自都有自己的x

初始值 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

但是t1t2是交替运行的,如果操作系统以下面的顺序执行t1t2

初始值 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 的计算结果正确,需要在 change_it() 方法外加上一把锁。当某个线程开始执行 change_it() 时,它会先获取锁,因此其他线程必须等待,直到该锁被释放后才能继续执行。

由于同一时间最多只有一个线程能够持有这把锁,就避免了多个线程同时修改 balance 所造成的冲突。在 python 中,可以通过 threading.lock() 来创建这样一把锁

balance = 0
lock = threading.lock()

def run_thread(n):
    for i in range(100000):
        # 先要获取锁:
        lock.acquire()
        try:
            # 放心地改吧:
            change_it(n)
        finally:
            # 改完了一定要释放锁:
            lock.release()

当多个线程同时调用 lock.acquire() 时,只有一个线程能成功拿到锁并继续执行,其余线程则会阻塞等待,直到锁被释放后才能依次获取并运行

线程在获取锁后,必须在使用完毕后及时释放。如果不释放,其他等待锁的线程就会一直被阻塞,最终造成死锁。

为避免这种情况,通常会使用 try...finally 结构来确保锁一定能被释放

锁的优点在于,它能保证某段关键代码只能由一个线程完整执行,从而避免并发冲突。但缺点也很明显:首先,锁会阻碍多线程的并行性,使相关代码实际退化为单线程执行,效率因此下降。

其次,如果系统中存在多个锁,不同线程各自持有一部分锁并同时尝试获取对方的锁,就可能陷入死锁状态,导致所有相关线程都无法继续运行,也不能正常结束,只能依靠操作系统强制终止

单例模式

单例模式

  • 类只能创建一个实例
  • 全局共享这个实例
import threading

class singleton:
    _instance = none
    _lock = threading.lock()  # 类级别锁,保证线程安全

    def __new__(cls, *args, **kwargs):
        if not cls._instance:  # 第一层检查,提高效率
            with cls._lock:    # 加锁,防止多线程同时创建
                if not cls._instance:  # 第二层检查,防止重复创建
                    cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, value=none):
        self.value = value

# 测试多线程
def task(name):
    obj = singleton(name)
    print(f"{name} -> {obj.value}, id: {id(obj)}")

threads = []
for i in range(5):
    t = threading.thread(target=task, args=(f"thread-{i}",))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

多核cpu

在多核 cpu 上,很多人会认为多个线程能够真正并行执行。那如果我们编写一个死循环线程,会发生什么呢?

通过 mac os x 的活动监视器或 windows 的任务管理器观察,可以发现:一个死循环线程会占满一个 cpu 核心(cpu 使用率 100%)。如果同时运行两个死循环线程,在多核环境下就会占用两个核心(cpu 使用率 200%)。因此,要想让 n 核 cpu 的所有核心都跑满,就需要同时启动 n 个死循环线程

import threading, multiprocessing

def loop():
    x = 0
    while true:
        x = x ^ 1

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

如果启动与 cpu 核心数量相同的 n 个线程,在 4 核 cpu 上观察,cpu 使用率可能只有约 102%,也就是只真正使用了一核

而用 c、c++ 或 java 实现相同的死循环,则可以轻松把所有核心跑满:4 核跑到 400%,8 核跑到 800%。为什么 python 做不到呢?

原因在于 python 虽然有真正的线程,但解释器存在一个 gil(global interpreter lock,全局解释器锁)。任何 python 线程在执行前都必须先获取 gil,每执行大约 100 条字节码后,解释器才会释放 gil,让其他线程有机会执行。

由于这个全局锁,python 的多线程只能交替运行,即使 100 个线程在 100 核 cpu 上,也只能用到一个核心 gil 是 python 解释器设计的历史遗留问题,尤其是官方的 cpython。要真正利用多核,除非使用不带 gil 的解释器或进行 c 扩展,否则 python 多线程无法有效利用多核

不过不用太担心:python 虽然不能用多线程充分利用多核,但可以通过多进程实现多核并行。每个 python 进程都有独立的 gil,互不干扰,因此可以充分利用多核 cpu

threadlocal

在多线程环境中,每个线程都有自己的数据。使用线程的局部变量比使用全局变量更安全,因为局部变量只对该线程可见,不会影响其他线程;而全局变量的修改则必须加锁才能避免冲突

不过,局部变量也有缺点:在函数调用时,需要手动传递,使用起来比较麻烦

def process_student(name):
    std = student(name)
    # std是局部变量,但是每个函数都要用它,因此必须传进去:
    do_task_1(std)
    do_task_2(std)

def do_task_1(std):
    do_subtask_1(std)
    do_subtask_2(std)

def do_task_2(std):
    do_subtask_2(std)
    do_subtask_2(std)

如果每个函数都一层层传递参数,那就太麻烦了。直接用全局变量也不行,因为每个线程处理的都是不同的 student 对象,不能共享

有没有办法把所有 student 对象放在一个全局字典里,然后用线程自身作为 key 来获取对应线程的 student 对象呢?

global_dict = {}

def std_thread(name):
    std = student(name)
    # 把std放到全局变量global_dict中:
    global_dict[threading.current_thread()] = std
    do_task_1()
    do_task_2()

def do_task_1():
    # 不传入std,而是根据当前线程查找:
    std = global_dict[threading.current_thread()]
    ...

def do_task_2():
    # 任何函数都可以查找出当前线程的std变量:
    std = global_dict[threading.current_thread()]
    ...

这种方法理论上是可行的,它最大的优点是解决了 student 对象在每层函数中传递的问题。不过,每个函数都需要去字典里获取对象,代码看起来有些冗长。有没有更简单的办法呢?这时,threadlocal 应运而生:它可以自动为每个线程管理数据,不需要手动查找字典

import threading
    
# 创建全局threadlocal对象:
local_school = threading.local()

def process_student():
    # 获取当前线程关联的student:
    std = local_school.student
    print('hello, %s (in %s)' % (std, threading.current_thread().name))

def process_thread(name):
    # 绑定threadlocal的student:
    local_school.student = name
    process_student()

t1 = threading.thread(target= process_thread, args=('alice',), name='thread-a')
t2 = threading.thread(target= process_thread, args=('bob',), name='thread-b')
t1.start()
t2.start()
t1.join()
t2.join()

执行结果

import threading
    
# 创建全局threadlocal对象:
local_school = threading.local()

def process_student():
    # 获取当前线程关联的student:
    std = local_school.student
    print('hello, %s (in %s)' % (std, threading.current_thread().name))

def process_thread(name):
    # 绑定threadlocal的student:
    local_school.student = name
    process_student()

t1 = threading.thread(target= process_thread, args=('alice',), name='thread-a')
t2 = threading.thread(target= process_thread, args=('bob',), name='thread-b')
t1.start()
t2.start()
t1.join()
t2.join()

全局变量 local_school 是一个 threadlocal 对象,每个线程都可以读写它的 student 属性,但互不干扰。

你可以把 local_school 看作一个全局变量,但它的属性(如 local_school.student)实际上是线程的局部变量,可以自由读写,不需要手动管理锁,threadlocal 会在内部处理这些细节

可以把 threadlocal 想象成一个特殊的字典,不仅可以用 local_school.student,还可以绑定其他变量,比如 local_school.teacher 等等

进程 vs. 线程

要实现多任务,通常会采用 master-worker 模式:master 负责分配任务,worker 负责执行任务。因此,多任务环境下一般是一个 master 配合多个 worker

多进程实现:主进程作为 master,其他子进程作为 worker

  • 优点:稳定性高,一个子进程崩溃不会影响主进程或其他子进程(当然如果 master 挂掉,所有进程都会受影响,但 master 只负责分配任务,挂掉的概率低)
  • 缺点:创建进程开销大。在 unix/linux 下用 fork 开销还可以接受,但在 windows 上创建进程代价很高。此外,操作系统能同时调度的进程数有限,如果同时运行几千个进程,操作系统甚至可能连调度都成问题

多线程实现:主线程作为 master,其他线程作为 worker

  • 优点:通常比多进程稍快,但提升有限
  • 缺点:危险性大,任何一个线程出错都可能导致整个进程崩溃,因为所有线程共享进程内存。在 windows 上,如果某个线程执行异常,你经常会看到“该程序执行了非法操作,即将关闭”的提示,实际上只是某个线程出错,但操作系统会强制结束整个进程

线程切换

无论是多进程还是多线程,当数量一多,效率往往不会提升,为什么呢?

我们打个比方:假设你正在准备中考,每天晚上需要完成语文、数学、英语、物理、化学五科作业,每科作业耗时 1 小时

  • 单任务模型:你先花 1 小时做语文作业,再花 1 小时做数学作业,依次完成所有作业,一共需要 5 小时
  • 多任务模型:你尝试轮流做每科作业,每科做 1 分钟再切换到下一科,如此循环。只要切换够快,从表面上看,你似乎同时在做 5 科作业,就像单核 cpu 在执行多任务一样

问题是,切换作业是有代价的。比如从语文切换到数学,你要先收拾语文书、钢笔(保存现场),再打开数学课本、找出直尺圆规(准备新环境)。操作系统在切换进程或线程时也类似:需要先保存当前执行环境(cpu 寄存器、内存页等),再恢复新任务的环境,才能开始执行。

虽然切换很快,但仍然消耗时间

如果同时有几千个任务,操作系统可能主要忙于切换任务,而没有足够时间真正执行任务。结果就是,多任务一旦过多,就会耗尽系统资源,效率急剧下降,所有任务都完成得很慢

计算密集型 vs. io密集型

在决定是否采用多任务时,另一个重要因素是任务类型。任务大体可分为计算密集型和 io 密集型两类

  • 计算密集型任务:主要消耗 cpu 资源,需要大量计算,例如计算圆周率、高清视频解码等。这类任务虽然可以使用多任务,但任务越多,花在切换任务上的时间也越多,cpu 执行效率反而下降。因此,为了最有效地利用 cpu,计算密集型任务同时运行的数量最好与 cpu 核心数相等。由于计算密集型任务依赖 cpu 性能,代码运行效率非常重要。像 python 这样的脚本语言运行速度较慢,不适合处理计算密集型任务;这类任务最好使用 c 语言编写
  • io 密集型任务:涉及网络或磁盘操作,cpu 消耗很少,大部分时间都在等待 io 完成(因为 io 速度远低于 cpu 和内存)。对于 io 密集型任务,增加任务数量可以提升 cpu 利用率,但也有上限。常见的 web 应用大多属于 io 密集型任务。由于执行期间绝大部分时间花在等待 io,使用运行速度更快的 c 语言替换 python 并不会显著提高效率。因此,对于 io 密集型任务,选择开发效率高、代码量少的语言最合适,脚本语言是首选,而 c 语言开发效率相对较低

异步io

由于 cpu 和 io 速度差异巨大,任务在执行过程中大部分时间都在等待 io,如果仍然使用单进程单线程模型,其他任务就无法并行执行。因此,我们才需要多进程或多线程来实现多任务并发

现代操作系统在 io 方面做了大量优化,其中最重要的就是对异步 io 的支持。如果充分利用异步 io,即便是单进程单线程,也能高效执行多任务,这就是所谓的事件驱动模型。例如 nginx 就是一款支持异步 io 的 web 服务器:在单核 cpu 上,它

用单进程就能高效处理大量请求;在多核 cpu 上,可以启动与核心数相等的进程,充分利用多核资源。由于系统总进程数有限,操作系统的调度也非常高效

在 python 中,单线程的异步编程模型称为协程

协程

可将协程想象成一个普通的 python 函数,但它具有一个超能力:在遇到可能需要一段时间才能完成的操作时,能够暂停执行。当长时间运行的操作完成时,可唤醒暂停的协程,并执行该协程中的其他代码。

当一个暂停的协程正在等待操作完成时,可运行其他代码,等待时其他代码的运行是应用程序并发的原因。还可同时运行多个耗时的操作,这能大大提高应用程序的性能。

当有一个长时间运行的操作时,await 关键字可以让我们暂停协程

关键字

async

async 关键字将函数标记为协程函数,而不是普通的 python 函数

async def coroutine():
    print("hello world")

这是一个简单的协程函数,不执行任何长时间的操作,它只是输出信息并返回。将上述协程放在事件循环中时,它将立即执行,因为没有任何阻塞i/o,没有任何操作暂停执行

async def coroutine_add_one(number):
    return number + 1
def add_one(number):
    return number + 1
function_result = add_one(1)
coroutine_result = coroutine_add_one(1)
print(function_result)
print(type(function_result))
"""
2
<class 'int'>
"""
print(coroutine_result)
print(type(coroutine_result))
"""
<coroutine object coroutine_add_one at 0x000002977045bac0>
<class 'coroutine'>
"""

调用普通的 add_one 函数时,它会立即执行并返回我们期望的一个整数。但当调用 coroutine_add_one 时,并不会执行协程中的代码,而是得到一个协程对象。当直接调用协程函数时,协程不会被执行。相反,它创建了一个可以稍后执行的协程对象,要执行协程,需要在事件循环中显式执行它。那么如何创建个事件循环并执行协程呢?

使用asyncio.run()函数来运行协程

import asyncio
async def coroutine_add_one(number):
    return number + 1
coroutine_result = asyncio.run(coroutine_add_one(1))
print(coroutine_result)  # 2

正如我们期望的一样,我们已经正确地将协程放在事件循环中,并且已经执行了它

asyncio.run 在这种情况下完成了一些重要的事情,首先创建了一个全新的事件循环。一旦成功创建,就会接受我们传递给它的任何协程,并运行它直到完成,然后返回结果,一切完成后,它会关闭并结束事件循环

await

asyncio的真正优势是能暂停执行,让事件循环在长时间运行的操作期间,运行其他任务

import asyncio
async def add_one(number):
    return number + 1
async def main():
    # main() 协程将暂停执行,直到 add_one(1) 运行完毕
    one_plus_one = await add_one(1)
    # main() 协程将暂停执行,直到 add_one(2) 运行完毕
    two_plus_one = await add_one(2)
    print(one_plus_one)
    print(two_plus_one)
asyncio.run(main())
"""
2
3
"""

在上面的代码中,我们两次暂停执行。

首先等待对 add_one(1) 的调用,一旦得到结果,主函数将取消暂停并将 add_one(1) 的返回值分配给变量 one_plus_one。然后对 add_one(2) 执行相同的操作,并输出结果。我们来将应用程序的执行流程可视化一下,如下图所示,图中的每个块代表一行或多行代码在任何给定时刻发生的事情

asyncio.sleep

使用 asyncio.sleep 让协程休眠给定的秒数,这将在预定的时间内暂停协程。由于 asyncio.sleep 本身是一个协程,所以必须将它与 await 关键字一起使用,如果单独调用它,会得到一个协程对象。既然 asyncio.sleep 是一个协程,这意味着当协程等待它时,其他代码也能够运行

import asyncio
async def hello_world():
    # 暂停 hello_world 协程一秒钟
    await asyncio.sleep(1)
    return "hello world"
async def main():
    # 暂停 main 协程,直到 hello_world 协程运行完毕
    message = await hello_world()
    print(message)
asyncio.run(main())
"""
hello world
"""

运行这个应用程序时,程序将等待 1 秒钟,然后输出打印信息。由于 hello_world 是一个协程,使用 asyncio.sleep 将其暂停 1 秒,因此现在有 1 秒的时间可以同时运行其他代码

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def add_one(number):
    return number + 1

async def hello_world():
    await delay(1)
    return "hello world"

async def main():
    # 暂停 main(),直到 add_one(1) 返回    
    one_plus_one = await add_one(1)
    # 暂停 main(),直到 hello_world() 返回
    message = await hello_world()
    print(one_plus_one)
    print(message)

asyncio.run(main())
"""
开始休眠 1 秒
休眠完成
2
hello world
"""

main 协程里面分别通过 await 驱动 add_one(1)hello_world() 两个协程执行,然后打印它们的返回值,但是在打印 one_plus_one 之前需要等待一秒,因为在 hello_world() 协程里面 sleep 了一秒。但我们真正想要的结果是,在 await sleep 的时候,立刻执行其它的代码,比如立刻打印 one_plus_one,但实际情况却没有。

这是为什么呢?答案是在 await 暂停当前的协程之后、以及 await 表达式给我们一个值之前不会执行该协程中的其他任何代码。因为 hello_world_message 函数需要 1 秒后才能给出一个值,所以主协程将暂停 1 秒。这种情况下,代码表现得好像它是串行的

事实上从源代码本身也能够理解,因为代码是一行一行写的,所以自然也要一行一行执行。而 await 后面跟一个协程之后,会驱动协程执行,并等到驱动的协程运行完毕之后才往下执行。因此这个逻辑就决定了,await 是串行的,一个 await 执行完毕之后才能执行下一个 await。如果我们想摆脱这种顺序模型,同时运行 add_onehello_world,那么需要引入一个被称为 “任务” 的概念

任务

直接调用协程时,并没有把它放在事件循环中运行,相反会得到一个协程对象。如果想运行,要么通过 asyncio.run,要么在一个协程里面通过 await 关键字进行驱动(在 a 协程里面 await b 协程,如果 a 协程运行了,那么 b 协程也会被驱动)

创建任务

创建任务是通过 asyncio.create_task 函数来实现的,当调用这个函数时,需要给它传递一个协程,然后返回一个任务对象。一旦有了一个任务对象,就可以把它放在一个 await 表达式中,它完成后就会提取返回值

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    # 将 delay(3) 包装成任务,注:包装完之后直接就丢到事件循环里面运行了
    # 因此这里会立即返回,而返回值是一个 asyncio.task 对象
    sleep_for_three = asyncio.create_task(delay(3))
    print("sleep_for_three:", sleep_for_three.__class__)
    # 至于协程究竟有没有运行完毕,我们可以通过 task 对象来查看
    # 当协程运行完毕或者报错,都看做是运行完毕了,那么调用 task 对象的 done 方法会返回 true
    # 否则返回 false,由于代码是立即执行,还没有到 3 秒钟,因此打印结果为 false
    print("协程(任务)是否执行完毕:", sleep_for_three.done())
    # 这里则保证必须等到 task 对象里面的协程运行完毕后,才能往下执行
    result = await sleep_for_three
    print("协程(任务)是否执行完毕:", sleep_for_three.done())
    print("返回值:", result)

asyncio.run(main())
"""
开始休眠 3 秒
sleep_for_three: <class '_asyncio.task'>
协程(任务)是否执行完毕: false
休眠完成
协程(任务)是否执行完毕: true
返回值: 3
"""

如果我们直接 await delay(3),那么在打印之前需要至少等待 3 秒,但通过将它包装成任务,会立即扔到事件循环里面运行。此时主程序可以直接往下执行,至于协程到底什么时候执行完毕、有没有执行完毕,则通过 task 对象(任务)来查看。当然你也可以 await 一个 task 对象,保证里面的协程运行完毕后才能往下执行

同时运行多个任务

任务是立即创建并计划尽快运行,这允许同时运行许多长时间的任务

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    sleep_for_three = asyncio.create_task(delay(3))
    sleep_again = asyncio.create_task(delay(3))
    sleep_once_more = asyncio.create_task(delay(3))

    await sleep_for_three
    await sleep_again
    await sleep_once_more

asyncio.run(main())
"""
开始休眠 3 秒
开始休眠 3 秒
开始休眠 3 秒
休眠完成
休眠完成
休眠完成
"""

在上面的代码中启动了三个任务,每个任务需要 3 秒才能完成。但由于对 create_task 的每次调用都会立即返回,因此会立即到达 await sleep_for_three语句,并且三个任务都丢到了事件循环,开启执行。由于 asyncio.sleep 属于 io,因此会进行切换,所以三个任务是并发执行的,这也意味着整个程序会在 3 秒钟左右完成,而不是 9 秒钟

随着我们添加更多任务,性能提升效果会更明显,比如启动了 10 个这样的任务,仍然只需要大约 3 秒,从而使速度提高 10 倍

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def hello_from_second():
    for i in range(10):
        await asyncio.sleep(1)
        print("你好,我每秒钟负责打印一次")

async def main():
    sleep_for_three = asyncio.create_task(delay(3))
    sleep_again = asyncio.create_task(delay(3))

    await hello_from_second()

asyncio.run(main())
"""
开始休眠 3 秒
开始休眠 3 秒
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
休眠完成
休眠完成
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
你好,我每秒钟负责打印一次
"""

在实际工作中,不要直接 await 一个协程,而是将协程包装成任务来让它运行。当你的代码逻辑依赖某个任务的执行结果时,再对该任务执行 await,拿到它的返回值

取消任务和设置超时

网络连接可能不可靠,用户的连接可能因为网速变慢而中断,或者网络服务器崩溃导致现有的请求无法处理。因此对于发出的请求,需要特别小心,不要无限期地等待。如果无限期等待一个不会出现的结果,可能导致应用程序挂起,从而导致精糕的用户体验在之前的示例中,如果任务一直持续下去,我们将被困在等待 await 语句完成而没有反馈的情况,也没有办法阻止这样的事情发生。因此 asyncio 提供了一个机制,允许我们手动取消任务,或者超时之后自动取消

取消任务

取消任务很简单,每个任务对象都有一个名为 cancel 的方法,可以在想要停止任务时调用它。取消一个任务将导致该任务在执行 await 时引发 cancellederror,然后再根据需要处理它

为说明这一点,假设启动了一个长时间运行的任务,但我们不希望它运行的时间超过 5 秒。如果任务没有在 5 秒内完成,就可以停止该任务,并向用户报告:该任务花费了太长时间,我们正在停止它。我们还希望每秒钟都输出一个状态更新,为用户提供最新信息,这样就可以让用户了解任务的运行状态

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    long_task = asyncio.create_task(delay(10))
    seconds_elapsed = 0

    while not long_task.done():
        print("检测到任务尚未完成,一秒钟之后继续检测")
        await asyncio.sleep(1)
        seconds_elapsed += 1
        # 时间超过 5 秒,取消任务
        if seconds_elapsed == 5:
            long_task.cancel()

    try:
        # 等待 long_task 完成,显然执行到这里的时候,任务已经被取消
        # 不管是 await 一个已经取消的任务,还是 await 的时候任务被取消
        # 都会引发 asyncio.cancellederror
        await long_task
    except asyncio.cancellederror:
        print("任务被取消")

asyncio.run(main())
"""
检测到任务尚未完成,一秒钟之后继续检测
开始休眠 10 秒
检测到任务尚未完成,一秒钟之后继续检测
检测到任务尚未完成,一秒钟之后继续检测
检测到任务尚未完成,一秒钟之后继续检测
检测到任务尚未完成,一秒钟之后继续检测
检测到任务尚未完成,一秒钟之后继续检测
任务被取消
"""

在代码中我们创建了一个任务,它需要花费 10 秒的时间才能运行完成。然后创建一个 while 循环来检查该任务是否已完成,任务的 done 方法在任务完成时返回 true,否则返回 false。每一秒,我们检查任务是否已经完成,并记录到目前为止经历了多少秒。如果任务已经花费了 5 秒,就取消这个任务。然后来到 await long_task,将输出 “任务被取消”,这表明捕获了一个 cancellederror

关于取消任务需要注意的是,cancellederror 只能从 await 语句抛出

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    long_task = asyncio.create_task(delay(3))
    # 立刻取消
    long_task.cancel()
    # 但 cancellederror 只有在 await 取消的协程时才会触发
    # 所以下面的语句会正常执行
    print("我会正常执行")
    print("hello world")
    print(list(range(10)))
    await asyncio.sleep(5)
    try:
        # 引发 cancellederror
        await long_task
    except asyncio.cancellederror:
        print("任务被取消")

asyncio.run(main())
"""
我会正常执行
hello world
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
任务被取消
"""

注意:如果任务在取消的时候已经运行完毕了,那么 await 的时候就不会抛 cancellederror 了

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    long_task = asyncio.create_task(delay(3))
    await asyncio.sleep(5)
    # 显然执行到这里,任务已经结束了
    long_task.cancel()
    try:
        await long_task
        print("任务执行完毕")
    except asyncio.cancellederror:
        print("任务被取消")

asyncio.run(main())
"""
开始休眠 3 秒
休眠完成
任务执行完毕
"""

所以对一个已完成的任务调用 cancel 方法,没有任何影响

设置超时并使用wait_for执行取消

每秒(或其他时间间隔)执行检查然后取消任务,并不是处理超时的最简单方法。理想情况下,我们应该有一个辅助函数,它允许指定超时并自动取消任务。

asyncio 通过名为 asyncio.wait_for 的函数提供此功能,该函数接收协程或任务对象,以及以秒为单位的超时时间。如果任务完成所需的时间超过了设定的超时时间,则会引发 timeoutexception,任务将自动取消为说明 wait_for 的工作原理,我们使用一个案例来说明:有一个任务需要 2 秒才能完成,但我们将它的超时时间设定为 1 秒。当得到一个 timeouterror 异常时,我们将捕获异常,并检查任务是否被取消

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    delay_task = asyncio.create_task(delay(2))
    try:
        result = await asyncio.wait_for(delay_task, 1)
        print("返回值:", result)
    except asyncio.timeouterror:
        print("超时啦")
        # delay_task.cancelled() 用于判断任务是否被取消
        # 任务被取消:返回 true,没有被取消:返回 false
        print("任务是否被取消:", delay_task.cancelled())

asyncio.run(main())
"""
开始休眠 2 秒
超时啦
任务是否被取消: true
"""

应用程序运行 1 秒后,wait_for 语句将引发 timeouterror,然后我们对其进行处理,并且 delay_task 被取消了。所以当一个任务超时的时候,会被自动取消

所以通过 wait_for 语句就很方便,如果直接 await 一个任务,那么必须等到任务完成之后才能继续往下执行。

如果任务一直完成不了,那么就会一直陷入阻塞。我们的目的是希望这个任务的执行时间是可控的,那么便可以使用 wait_for 并指定超时时间。

注:使用 wait_for 必须要搭配 await,阻塞等待任务完成并拿到返回值、或者达到超时时间引发 timeouterror 之后,程序才能往下执行因此 “await 任务” 和 “await asyncio.wait_for(任务, timeout)” 的效果是类似的,都是等待后面的任务完成并拿到它的返回值。但使用 wait_for 可以指定超时时间,在规定时间内如果没有完成,则抛出 timeouterror,而不会一直陷入阻塞

如果任务花费的时间比预期的长,在引发 timeouterror 之后自动取消任务通常是个好主意。否则,可能有一个协程无限期地等待,占用永远不会释放的资源。但在某些情况下,我们可能希望保持协程运行。

例如,我们可能想通知用户:某任务花费的时间比预期的要长,但即便超过了规定的超时时间,也不取消该任务。为此,可使用 asyncio.shield 函数包装任务,这个函数将防止传入的协程被取消,会给它一个屏蔽,将取消请求将忽略掉

import asyncio

async def delay(seconds):
    print(f"开始休眠 {seconds} 秒")
    await asyncio.sleep(seconds)
    print(f"休眠完成")
    return seconds

async def main():
    delay_task = asyncio.create_task(delay(2))
    try:
        # 通过 asyncio.shield 将 delay_task 保护起来
        result = await asyncio.wait_for(asyncio.shield(delay_task), 1)
        print("返回值:", result)
    except asyncio.timeouterror:
        print("超时啦")
        # 如果超时依旧会引发 timeouterror,但和之前不同的是
        # 此时任务不会被取消了,因为 asyncio.shield 会将取消请求忽略掉
        print("任务是否被取消:", delay_task.cancelled())
        # 从出现超时的地方,继续执行,并等待它完成
        result = await delay_task
        print("返回值:", result)

asyncio.run(main())
"""
开始休眠 2 秒
超时啦
任务是否被取消: false
休眠完成
返回值: 2
"""

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com