自学Python第十七天-多线程、多进程、协程、子进程

自学Python第十七天-多线程、多进程、协程

线程、进程、协程的概念

当运行一个可执行程序后,会在内存中加载这个程序,也就是创建了一个程序的进程,所以进程是一个资源单位,是计算机分配资源的 最小单位。

一个进程可以使用一个CPU执行,也可以使用多个CPU执行,每个单独的执行步骤称为一个线程,所以线程是一个执行单位,是计算机可以被CPU调度的最小单位。

在单线程条件中,当程序遇见 IO 等操作时,线程会处于阻塞状态,可以选择性的切换到其他的任务上。在宏观上看是多个任务一起执行,这就是协程,也就是多任务异步操作。

需要注意的是多线程是并发程序(同一时间内只有一个线程执行,其他线程挂起),而多进程是并行程序(利用CPU的多核机制同时执行多个线程)。

另外进程只是一个资源单位,表示给进程程序分配的内存,进程本身并不执行程序内容。但是进程被创建,内存分配完成后,操作系统会自动创建一个线程来执行其中的程序内容

多线程

默认执行程序是单线程的顺序执行,即一步一步执行。 python 可以使用 threading 库来创建多线程执行程序。即不管一些程序代码是否执行完成,立即执行另一段程序代码。

创建多线程任务

from threading import Thread  # 线程类
import threading

def func(name):
    for i in range(1000):
        print(name, threading.current_thread().name, i)			# current_thread() 方法能够获取当前线程对象的引用

if __name__ == '__main__':
    t = Thread(target=func, args=('线程',), name='第一子线程')  # 创建线程执行目标任务,传参必须是元组
    t.start()  # 多线程状态为可以开始工作,具体执行时间由 CPU 决定
    for i in range(1000):
        print('main', i)

也可以写成类的形式:

from threading import Thread

class MyThread(Thread):		# 自定义的线程任务类
	def run(self):		# 固定的,执行的任务
		for i in range(1000):
			print('子线程', i)

if __name__ == '__main__':
	t = MyThread()
	t.start()		# 开启线程执行 run() 方法
	for i in range(1000):
		print('主线程', i)

需注意的是,多线程执行过程完全由CPU决定,从例子的执行结果看,两个线程完全是随机穿插的。所以在多线程编程时需要注意是否会进行干扰。另外多线程执行时速度不一定,哪个线程先执行完成也不一定,所以如果需要使用另一个线程中的数据,或者需要等待子线程的执行完成时,也需要注意。通常会用到多线程通信来获取数据,或使用 join 方法将某个子线程添加到当前线程中,进行同步执行(等待该子线程执行完毕,如果未运行完则阻塞)。

守护线程

默认情况下主线程关闭前会等待所有子线程,即主线程需要等待子线程执行完毕后才结束。
可以使用更改线程对象 daemon 情况的方法,将某个线程设置为守护线程,当主线程执行完毕后,子线程直接关闭。
不同于 join 方法,守护线程只用于主线程执行完毕后需要关闭的情况下,而 join 方法可以加入到其他子线程,等待加入的子线程执行完毕后才执行之后的命令。
需注意的是,守护线程需要在 start 之前设置。

t = Thread(target=func, args=('线程',))
t.daemon = True		# 设为守护线程,也可以使用 t.setDaemon(True) 进行设置,此方法在未来会被遗弃
# t.setDaemon(False)	# 设为非守护线程(默认)
t.start()				
# 主线程会等待子线程执行完成,但是不会等待守护线程执行完成

多线程通信

多线程只是执行的顺序不一样,其使用的资源还是一样的,所以可以使用共用的资源做到线程间通信,例如全局变量等。但是可能会出现一些问题(见线程同步),所以通常使用生产者与消费者在两个线程之间进行通信。

python 的 queue 模块提供了同步的、线程安全的队列类,包括FIFO队列 Queue,LIFO 的栈队列 LifoQueue,优先级队列 PriorityQueue。

import threading, queue, random, time

def produce(q):		# 生产者
    i = 0
    while i < 10:
        num = random.randint(1, 100)
        q.put(f'生产者产生数据:{num}')
        print(f'生产者产生数据:{num}')
        time.sleep(1)
        i += 1
    q.put(None)
    # 完成任务
    print('停止生产')
    q.task_done()

def consume(q):		# 消费者
    while 1:
        item = q.get()
        if item is None:
            break
        print(f'消费者获取到{item}')
        time.sleep(4)
    # 完成任务
    q.task_done()

if __name__ == '__main__':
    q = queue.Queue(10)		# 创建一个容量为 10 的队列
    arr = []
    # 创建生产者
    th = threading.Thread(target=produce, args=(q,))
    th.start()
    # 创建消费者
    tc = threading.Thread(target=consume, args=(q,))
    tc.start()

终止线程

多线程不能直接终止,但是可以自定义函数将线程强制终止(网上找的代码,可用,啥意思看不懂):

def stop_thread(t):  # 强制停止线程 t
    import inspect, ctypes
    tid = ctypes.c_long(t.ident)
    if not inspect.isclass(SystemExit):
        exctype = type(SystemExit)
    else:
        exctype = SystemExit
    res = ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, ctypes.py_object(exctype))
    if res == 0:
        raise ValueError('invalid thread id')
    elif res != 1:
        ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, None)
        raise SystemError("PyThreadState_SetAsyncExc failed")

线程安全、线程同步

如果多个线程同时对一个资源操作,可能会出现一些不希望看到的情况(注:python 3.10 以上版本此操作升级为线程安全):

from threading import Thread  # 线程类

def run1():
    global n
    for i in range(10000000):
        n += 1
    print(f'run1中n的值是{n}')

def run2():
    global n
    for i in range(10000000):
        n += 1
    print(f'run2中n的值是{n}')

if __name__ == '__main__':
    n = 0
    t1 = Thread(target=run1)
    t2 = Thread(target=run2)
    t1.start()
    t2.start()

例如这个实例,对于用户来说,结果应该是一个 n 的值为 20000000,但是实际运行后会发现结果是两个值均远小于 20000000 (注:再次强调,3.10 以上的版本为期望值,因为此操作升级为线程安全的操作)。

出现这个结果是因为正常情况下 n += 1 这个指令是分为计算 n+1 和给 n 赋值这两步的。当一个线程执行了 n+1 这个步骤后,被 cpu 暂停转而分配运算资源给另一个线程,就会出现了另一个线程使用没有增加的 n 来进行运算的情况,而当返回原线程进行赋值时,赋的值还是原来的值。所以会出现重复赋值的情况,使得结果小于期望值。

要处理这种情况,需要用到线程同步的概念,给共享的全局变量添加。其中心思想就是当某个线程对全局变量进行操作时,添加一把锁,然后再进行操作,当操作完成时解开这把锁。而其他的线程需要对全局变量操作时,发现已经被锁上了,则停止操作行为,等待锁解开。所以同一时间只有一个线程能够操作全局变量,相当于对全局变量的操作是同步(非异步)的。

使用 Thread 对象的 Lock 和 Rlock 可以实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于每次只允许一个线程操作的数据,可以将其操作放到这连个方法之间。

from threading import Lock 

# 创建锁对象
lock = Lock()
list1 = [0] * 10		# 共享数据

def task1():
    # 获取线程锁,如果已经上锁,则等待锁的释放
    lock.acquire()  # 可以阻塞
    for i in range(len(list1)):
        list1[i] = 1
        time.sleep(0.1)
    # 操作完成,释放锁
    lock.release()

def task2():
    lock.acquire()
    for i in list1:
        print(i)
        time.sleep(0.1)
    lock.release()

if __name__ == '__main__':
    t1 = Thread(target=task1)
    t2 = Thread(target=task2)
    t1.start()
    t2.start()

第一个实例中,小数据运算为期望结果,大数据运算会出现非期望结果,而其他一些语言使用多线程处理类似任务时,即使数值很小也会频繁出现这种情况。这是因为 python 的底层会默认添加GIL,即Global Interpreter Lock 全局解释器锁(不管是否真的需要锁),以保证一个进程中同时只有一个线程可以被CPU调用,也因此会造成 python 的多线程成为伪多线程(python 会模拟并发执行,会尝试切换线程,锁会在当前线程阻塞时候释放,或执行够一定数量的代码后释放),进而效率会大幅度降低(放弃了多核、多线程的优势)。python 希望解除 GIL 的限制,但是由于种种原因暂未能实现,所以 python 处理少量数据的多线程任务时结果是期望值(因为GIL降低了对共享数据操作的概率),而处理大量数据的多线程任务会出现偏离期望值的情况。

线程安全的操作

有些操作是自带锁(内部集成了锁的机制)的,或者说是无法分割的,也叫原子操作,这种操作就是线程安全的。例如加减法、赋值、列表的 append 方法、pop 方法等,这些操作无需再使用锁处理。是否线程安全需要查看开发文档。

Lock 和 Rlock 的区别

Lock 和 Rlock 都是常用锁,都可以使用上下文管理器,从而不用手动上锁解锁。
Lock 和 Rlock 在使用语法上是一样的,其区别在于:

  • 称呼:Lock 被称为原始锁、同步锁互斥锁,Rlock 被称为重入锁递归锁
  • 所属线程:Lock 在锁定时不属于特定线程,所以可以被任意线程解锁;Rlock 锁定时只能被上锁的线程解锁,其他线程解锁会报错。
  • 嵌套:Lock 不能被嵌套,即加锁必须在未锁定状态下调用,解锁必须在锁定状态下调用,尝试释放非锁定的锁会报错。Rlock 可以嵌套,即可以多次被锁定,需要相对应次数的解锁。
# Lock/Rlock 的 with 使用方法
import threading
lock = threading.RLock()		# 也可以使用 Lock 

def task():
	with lock:
		pass
Lock 和 Rlock 的使用场景

在简单的多线程应用中,使用 Lock 锁可以提高执行效率。但是复杂的场景,尤其是多人开发或者函数代码复用的场景中,可能会出现使用的函数内部使用了锁,但开发者并不知道,从而在自己的代码中添加了锁,出现了锁的嵌套的情况。此时就不能够使用 Lock 了,只能够使用 Rlock 了。

死锁

死锁原因之一是使用 Lock 出现嵌套,因为 Lock 不能将已经上锁的锁再次上锁,所以线程会一直等待锁的释放而产生阻塞,没有线程能够释放锁就形成了死锁。

另外,如果两个线程分别占有一部分资源并且同时等待对方的资源,由于本身资源不会释放又得不到对方的资源,就会造成死锁。

尽管死锁很少发生,但一旦发生就会造成应用的停止响应。例如:

from threading import Thread, Lock  # 线程类
import time

lockA = Lock()
lockB = Lock()

class MyThreadA(Thread):
    def run(self):
        if lockA.acquire():  # 如果能够获取锁则返回 True
            print(f'{self.name}获取了A锁')
            time.sleep(0.1)
            if lockB.acquire():
                print(f'{self.name}同时获取了A锁和B锁')
                lockB.release()
            lockA.release()

class MyThreadB(Thread):
    def run(self):
        if lockB.acquire():  # 如果能够获取锁则返回 True
            print(f'{self.name}获取了B锁')
            time.sleep(0.1)
            if lockA.acquire():
                print(f'{self.name}同时获取了A锁和B锁')
                lockA.release()
            lockB.release()

if __name__ == '__main__':
    tA = MyThreadA(name='线程1')
    tB = MyThreadB(name='线程2')
    tA.start()
    tB.start()

所以通常会在 acquire 方法中添加 timeout 参数,设置阻塞的超时时长,来避免死锁。

多进程

因为增加一个进程会开辟相应的内存空间,消耗相应的资源,所以一般不推荐使用多进程编程(但是 python 的多线程是伪多线程,所以如果需要提升效率,官方推荐使用多进程来处理)。

需要注意的是,由于操作系统的机制不同,unix 系统会将几乎所有资源“拷贝”一份进行使用,而 windows 系统则只能将需要使用的资源通过参数传递给子进程,且不支持文件对象或线程锁等对象的传参。

创建进程的方式可能根据操作系统的不同有所区别,例如 windows 使用 spawn 方式,linux 使用 fork 方法,macOS 两种方法都支持(默认spawn),unix 也是支持两种方法(默认fork)。

  • spawn: 并非继承(或者拷贝)父进程的全部资源,而是主动传入进程对象run方法所需的资源,子进程会拷贝一份传递进来的资源。
  • fork: 复制父进程的全部资源。

进程的数量

和多线程不同,多进程时通常每个进程会使用一个 cpu (现在 cpu 会有虚拟内核,也可以处理并发运算,例如 8 核的CPU可能能够使用的核是16核)并发运算,超出这个数量的进程 cpu 就会进行进程的切换动作,反而会造成执行效率下降。可以使用 multiprocessing.cpu_count() 方法获取可以使用的 cpu 数量。使用时需注意主进程也会占用一个 cpu ,其他程序或许也会占用 cpu。

在工作中,任务数往往会大于 CPU 的核数,即一定会有一些任务在执行,一些任务在等待 CPU 执行,因此导致了进程有不同的状态:

  • 就绪:运行的条件已经具备,等待分配 CPU 执行任务
  • 执行:CPU 正在执行进程任务
  • 等待:等待某些条件满足,例如 sleep

创建多进程任务

使用 multiprocessing 库可以创建多进程。

from multiprocessing import Process
from time import sleep

def func():
    for i in range(100):
        print('子进程', i)
        sleep(0.2)

if __name__ == '__main__':
    p = Process(target=func)
    p.start()
    for i in range(100):
        print('主进程', i)
        sleep(0.1)

面向对象的代码和多线程类似。同多线程类似,多进程执行时,各进程执行的速度不一定,所以需要获取其他进程的数据时需要使用进程间的通信,如果需要等待其他进程执行完,可以使用 join 方法。

多进程传参和多线程传参一样,将参照作为元组传递给 args 参数即可。

在进程中,可以使用 os.getpid() 方法来获取当前进程的 pid (进程号)。如果该进程是子进程,则可以使用 os.getppid() 方法获取父进程的 pid。

另外需要注意的是,多进程编程的主代码需要写到 '__main__' 主入口中,不然 windows 系统会报错(unix 系统因为机制不一样,所以不会报错)。

守护进程

进程的守护概念和线程的守护一样,只是使用方法不一样。

from multiprocessing import Process
import multiprocessing, os


def task():
    print(multiprocessing.current_process().name, os.getpid())  # 当前进程名称,子进程PID
    print(os.getppid())  # 主进程PID


if __name__ == '__main__':
    p = Process(target=task)
    p.name = '子进程'	# 进程的名称
    p.daemon = True     # 进程守护
    p.start()
    p.join()

进程间的数据共享和通信

多进程中每个进程均是一个副本,其使用的资源均是独立的。即使使用了同一个全局变量,每个进程使用的其实均是此全局变量的一个副本,而不是共用的关系。有时候需要使用数据共享或进行通信,python 提供了4种方法。

  • Value 和 Array : 使用 python 底层的一些代码封装成的类,基本思想是根据定义的数据类型开辟固定的内存空间,然后直接让不同的子进程访问同一块内存空间。使用起来很麻烦,且不能更改空间大小(数据类型和数据数量)。
  • Manager : 可以使用 Manager 类创建特定的能够共享数据的字典或列表,比 Value 和 Array 方便了很多。
  • Queue : 队列可以存放任意的数据,遵循FIFO(先进先出)原则,且有等待(阻塞)机制让不同进度的子进程正确的进行通信。有些类似于多线程通信中的 Queue。
  • Pipe : 管道是类似于双向的队列,一方发送另一方进行接收。如果接收方无法接收到数据,则进行阻塞,直到接收到数据为止。

这些方法在实际项目开发中使用的很少,一般会借助第三方来做资源共享,例如数据库、redis等。

管理者 Manager

from multiprocessing import Process, Manager

def f(d, l):
	d[1] = '1'			# 共享的字典数据可以和字典一样使用
	d['2'] = 2
	d[0.25] = None
	l.append(666)		# 共享的列表数据可以和列表一样使用

if __name__ == '__main__':
	with Manager() as manager:		# 创建 manager 对象
		d = manager.dict()			# 使用 manager 创建一个能够共享数据的字典
		l = manager.list()			# 使用 manager 创建一个能够共享数据的列表
		
		p = Process(target=f, args=(d, l))
		p.start()
		p.join()		# 等待子进程完成
		
		print(d)		# 获取共享的字典
		print(l)		# 获取共享的列表

队列 Queue

使用 put 方法将数据添加进队列,使用 get 方法从队列弹出数据。

需要注意的是,默认情况下如果 queue 满了使用 put() 方法会阻塞进程,等待队列出现空位,再添加数据进入队列。也可以使用该方法时设置参数 block 不进行阻塞或使用参数 timeout 设置超时,到达条件则抛出异常。通常在使用时,会使用 full() 方法判断队列是否满了。或使用 put_nowait 方法,等同于 block=False。

默认情况下如果 queue 空了再使用 get() 方法也会阻塞进程,同 put() 方法,可以设置 block 、timeout 参数,也可以使用 empty() 方法进行判断。或使用 get_nowait() 方法,等同于 block=False。

from multiprocessing import Queue, Process
# Queue 也可以通过 queue 包导入 from queue import Queue
import time

def download(q):
    for i in range(30):
        print(f'处理第 {i} 个文件中')
        time.sleep(1)
        print(f'{i}处理完成,开始保存')
        q.put(i)

def getfile(q):
    while 1:
        try:
            print(f'队列里有{q.qsize()}个任务')
            file = q.get(timeout=5)
            time.sleep(2)
            print(f'第 {file} 个文件保存成功')
        except:
            print('全部保存完成')
            break

if __name__ == '__main__':
    q = Queue(5)	# 参数是队列最大保存多少个,可以省略
    p1 = Process(target=download, args=(q,))
    p2 = Process(target=getfile, args=(q,))
    p1.start()
    p2.start()

如果不使用 while 则任务执行一次就会结束进程,而不会等待队列添加数据继续执行。

队列也有 join() 方法,可以将阻塞当前线程,直到队列计数器为0。需注意的是,put() 方法执行计数器增加1,而 get() 方法执行并不会让计数器减少。必须使用 task_done() 方法令计数器减少1。需要注意的是, multiprocession 下的 Queue 对象并不支持 task_done() 方法,需要使用计数器操作,使用可等待队列 JoinableQueue 对象。

from multiprocession import Queue

q = Queue()
q.put(1)
q.put(2)
q.get()
q.get()
q.join()			# 程序执行到这里会卡住,因为 get() 方法并不减少计数器,所以计数器并不是0

print('执行结束')
from multiprocession import JoinableQueue as Queue

q = Queue()
q.put(1)
q.put(2)
q.put(3)
q.task_done()		# 计数器减少1
q.task_done()		
print(q.get())		# 结果是1,计数器减少并不影响队列里的数据和队列大小
print(q.get())
q.task_done()
q.join()

print('执行结束')		

需要使用到 join() 方法进行阻塞时,一般会让 task_done()get() 方法成对出现。

管道 Pipe

管道使用 send 函数发送数据,使用 recv 数据进行接收,由于是双向的,管道两方发送和接收互不影响。如果没有接收到数据(管道里没有数据)则会进行阻塞。

import multiprocessing, random

def task(conn):
    conn.send(random.random())  # 向管道发送数据
    data = conn.recv()      # 从管道接收数据
    print(f'接收到数据{data}')

if __name__ == '__main__':
    conn1,conn2 = multiprocessing.Pipe()
    p1 = multiprocessing.Process(target=task,args=(conn1,))     # 子进程1,使用管道的一头
    p2 = multiprocessing.Process(target=task,args=(conn2,))     # 子进程2,使用管道的另一头
    p1.start()
    p2.start()

进程锁

类似于多线程,当多个进程对同一个资源进行操作时(Value、Array、Manager 或者文件资源),也有可能会因为执行速度、进程的切换等原因造成操作结果偏离预期结果,即数据混乱,所以也需要使用到。进程锁的使用和注意同线程锁,唯一不同的在于锁来自于不同的库。windows 不支持线程锁传参,但支持进程锁传参。

import multiprocessing

lock = multiprocessing.Rlock()

需注意windows系统使用进程锁时,如果主进程执行完了,等待子进程执行完后会关闭程序,但是主进程执行完后会释放资源,进程锁也会被释放掉,此时子进程调用就会出错。所以需要使用 join 等方法等待子进程的执行。

终止进程

进程可以直接关闭,使用 p.terminate() 方法可以强制终止进程 p。

多进程(进程池)注意

使用多进程时,会讲所有的资源对象复制一遍(根据操作系统的不同复制的资源不同),但是有一些情况下会出错:

  • 在类中使用多进程的时候,如果同时使用了数据库连接对象,则数据库连接对象不能写在初始化方法中,而是需要放置在类属性中,否则会出现序列化失败的问题。这样数据库连接对象只有一个,而不是初始化一次对象生成一个新的数据库连接对象。

合并线程(进程)

当需要等待某一线程结束时,可以使用 join 方法将其加入到当前线程中,这样这两个线程就是同步的了。多进程任务也是如此

from threading import Thread
import time

def test(n):		# 多线程执行的函数
	print(f'{n}线程开始执行')
	time.sleep(2)	
	print(f'{n}线程结束')

thread_obj_list = list()		# 创建一个线程列表
for i in range(10):		# 创建多个线程
	t = Thread(target=test, args=(i,))		# 创建线程对象
	thread_obj_list.append(t)			# 将线程添加到线程列表中
	t.start()		# 开始执行子线程

for t_obj in thread_obj_list:
	t_obj.join()	# 将该子线程合并到当前线程中,等子线程执行完成才会继续执行下一指令,即变为同步执行。

print('所有子线程执行完毕')
# 如果没有使用 join 方法合并线程,可能子线程还没有执行完成,主线程就打印出最后的执行完成提示。

线程池、进程池

线程和进程也是需要消耗资源的,不是随便增加的。比如爬取一个网站的数据,这个网站有好几万个页面,如果每个页面使用一个线程或进程则会消耗尽系统资源。通常情况下会一次性开辟一个有一定数量进/线程的进/线程池,用户提交任务给进/线程池,进/线程任务的调度则由进/线程池负责。

阻塞和非阻塞

使用进/线程池的时候,会将任务发给池子。阻塞式指的是当池子中有任务执行时,不再添加和执行新任务,直到旧任务结束。非阻塞式则是将全部任务添加到队列中,然后立刻返回,不用等待子进程执行完成就继续执行主进程或其他子进程,只要池子有空位就会将队列中的任务添加到池子执行,通过回调函数获取执行结果。

阻塞式类似于线性编程,所以一般使用多进程、多线程时,为了提高效率,都会使用非阻塞式。

线程池

使用 concurrent.futures 创建线程池

from concurrent.futures import ThreadPoolExecutor

def fn(name):  # 线程任务
    for i in range(1000):
        print(name, i)

if __name__ == '__main__':
    # 创建线程池
    with ThreadPoolExecutor(5) as t:  # 创建一个拥有5个线程的线程池
        for i in range(100):        # 创建100个子任务
            t.submit(fn, f'线程{i}')     # 子任务提交给线程池
    # 等待线程池的任务全部执行完毕,才继续执行
    print('Over!')
from concurrent.futures import ThreadPoolExecutor

def fn(name):  # 线程池任务
    for i in range(1000):
        print(name, i)
    return name

def done(future):		# 回调函数,参数是 future 对象,从 future.result() 获取返回的具体参数
	name = future.result()
	print(name, '执行完成')	

if __name__ == '__main__':
	tasks = []
    # 创建线程池
    pool = ThreadPoolExecutor(5)
    for i in range(100):
    	t = pool.submit(fn, f'线程{i}')		# 线程池执行子任务
    	t.add_done_callback(done)		# 添加回调函数 done
    	tasks.append(t)		# 任务添加到列表中
    print('执行中')
    pool.shutdown()		# 线程池任务结束,释放资源
    print('线程池执行完毕')
    for t in tasks:
    	print(t.result())		# 可以使用 result 方法获取每个任务的返回结果

和多线程一样,主线程不会等线程池里的任务执行完才继续执行(使用了 with 会等待 with 的关闭),但是可以使用 ThreadPoolExecutor.shutdown() 的方法手动关闭线程池。

这个例子是循环添加线程,也可以使用 map 方法映射进线程池。

with ThreadPoolExecutor(50) as t:
	t.map(fn, range(100))

需注意的是,map() 方法第2个参数必须是一个可迭代对象。如果需要返回值,则返回值也是个可迭代对象。

也使用 as_completed 将线程对象打包为生成器

from concurrent.futures import ThreadPoolExecutor, as_completed, wait

str_list = ['1', '2', '3']
def test(s):
	print(s)

pool = ThreadPoolExecutor(5)
for s in str_list:
	pool.submit(test, s)				# 使用 submit 提交

for res in pool.map(test, str_list):			# 使用 map 提交
	pass

futures = [pool.submit(test, s) for s in str_list]		# 使用生成器提交,返回可迭代的 futures 对象,包含线程信息
for future in futures:
	print(future.result())		# 可以查看线程任务结果,但是此方法是同步的,即线程池中所有任务都执行完成才有结果

# 如果想异步的查看线程任务结果,使用 as_complete 方法,该方法将返回一个生成器对象,每有一个任务完成就会添加至生成器中,所以是异步的
for future in as_completed(futures):
	print(future.result())

wait(futures)	# 阻塞,等待线程池中所有方法完成,有点像 shutdown() 方法,但是并不释放资源

进程池

python2 是没有线程池只有进程池的,就是使用 multiprocessing 库。到了 python3 增加了线程池的库 concurrent.futures ,且将进程池也合并到此模块中了。两种方法间目前没感觉有太大的区别,不过用法上使用 concurrent.futures 进程池和线程池几乎一摸一样,所以推荐使用。进程池创建太大反而会影响执行效率,具体数量可以参考多进程的数量

另外需要注意的是,进程池的进程锁需要使用 Manager 类的 Lock 或 Rlock 来实现。

使用 multiprocessing 创建进程池

from multiprocessing import Pool
import time, random, os

container = []

def task(name):
    print('任务开始', name)
    start = time.time()
    time.sleep(random.random() * 2)
    end = time.time()
    return f'任务{name}完成,用时:{end - start},pid={os.getpid()}'

def callback_task(s):		# 回调函数
    container.append(s)

if __name__ == '__main__':
    # 创建进程池对象,参数为最大进程数量
    pool = Pool(5)
    for i in range(20):  # 创建20个任务
        # 进程池添加并执行任务
         # apply_async 异步,即池子是非阻塞模式
         # apply 方法是同步(阻塞式),且没有回调函数
        pool.apply_async(task, args=(f'任务{i}',), callback=callback_task) 
    pool.close()  # 结束添加任务,如果不使用这个方法 join 会报错
    pool.join()  # 进程池插入主进程,即等待进程池结束
    
    for c in container:
        print(c)

使用 concurrent.futures 创建进程池

from concurrent.futures import ProcessPoolExecutor

def fn(name):  # 进程池任务
    for i in range(1000):
        print(name, i)

if __name__ == '__main__':
    # 创建进程池
    with ProcessPoolExecutor(5) as p:  # 创建一个拥有5个进程的进程池
        for i in range(10):        # 创建10个子任务
            p.submit(fn, f'线程{i}')     # 进程池执行子任务
    # 等待进程池的任务全部执行完毕,才继续执行
    print('Over!')

可见线程池的方法和功能可以用于进程池。有区别的在于,回调函数的调用是由主进程处理(线程池还是由子线程处理)。

线/进程池实现异步操作

线/进程池实现异步操作会使用到 concurrent.futures.Future 对象,和后面协程的 asyncio.Futrue 对象虽然都是用于异步操作的,但不是同一个对象。

import time
from concurrent.futures import Future, ThreadPoolExecutor, ProcessPoolExecutor

def func(value):
    time.sleep(1)
    print(value)
    return value

# 创建线程池
pool = ThreadPoolExecutor(5)
for i in range(10):
    fut = pool.submit(func, i)
    print(fut, type(fut))

这个例子中线程池最大可以执行5个任务,而任务有10个,所以线程池中任务执行完一个后再添加一个任务执行。但是 fut 会不等任务执行完成就获得。fut 就是一个 Future 对象,在添加任务到线程池后就创建了此对象,只是此对象的内容要在任务执行完成后才会添加。

多线程、多进程的选择

运算量大(计算密集型)的程序需要利用多个、多核CPU的优势,选择多进程比较合适。因为每个进程会独立的进行CPU运算处理独立的资源,不会出现线程同步造成计算瓶颈,也不会出现没有锁造成数据不准确的情况,且会请求不同的CPU(核)处理。

耗时比较长的程序会长时间占用存储资源(资源密集型),使用多线程比较合适。使用多进程会浪费很多存储资源在慢运行(甚至等待)环境中。

协程

线程、进程是基于操作系统和 CPU 的调度分配用于实现并发编程的,是真实存在的,而协程是程序员通过代码进行执行切换造成的一种伪并发多任务异步操作,是由一个线程通过程序主动控制在代码块中切换执行从而形成程序并发执行的假象。因为创建对象和进程、线程调度也是需要时间的,而协程免去了这些步骤,所以大部分常用的情况下协程的工作效率是最高的。

python 中有多种方法能够进行协程操作,这里只研究比较简单方便常见的方法。

实现协程操作有很多种方法,这里举例几种方法:

  • yield 关键字:由于 yield 每次返回后停止执行并等待调用再继续执行的特性,可以人为的控制切换行为,所以可以用作协程。但是使用起来相当麻烦,而且很多时候是无意义的操作,所以基本不使用这种协程方式。
  • greenlet 模块:greenlet 是一个早期的第三方的协程模块
  • gevent 模块:一个第三方的协程模块,因为 greenlet 模块使用的不便,基于此模块做了一些加强。最经典的就是猴子补丁
  • asyncio 模块:asyncio 模块是 python3.4 添加的模块,是一种装饰器,用来进行异步的IO操作
  • async、await 关键字:python3.5 添加的功能,也是官方推荐的协程方式。

使用 yield 关键字(不推荐)

def func1():	
	yield 1					# 1
	yield from func2()		# 2 跳转到 func2
	yield 2					# 5

def func2():
	yield 3					# 3
	yield 4					# 4

f1 = func1()			# 执行了生成器函数 func1 ,返回一个生成器 f1
for item in f1:
	print(item)			# 循环执行生成器

在某些需求中,可以使用此方法(但是目的并不是使用协程函数):希望在 yield 函数中处理专用的数据,或操作特定自动化页面,或执行特定的操作。并且在有一定结果或特殊时期暂时当前的执行(例如自动化操作页面时页面跳转了,希望使用另一个函数来操作跳转后的页面;或数据需要另外的一些处理等),在适当的时期继续执行当前函数(也可以传入新的参数)。

def func1():		# yield 函数
	a = yield 1
	print(a)
	b = yield 2
	print(b)
	c = yield 3
	print(c)

f1 = func1()		# 创建生成器
print(next(f1))		# 第一次使用生成器,因为是一步一步操作,所以使用 next() 函数
"""
输出结果:
1			由 yield 1 返回
"""
print(f1.send('f1.1'))		# 继续执行生成器可以使用 next(),这里需要向生成器里传送数据,使用 send() 方法
"""
输出结果:
f1.1		先给参数 a 赋值,执行 print(a)
2			由 yield 2 返回
"""
print(f1.send('f1.2'))
"""
输出结果:
f1.2
3
"""
print(f1.send('f1.3'))
"""
输出结果:
f1.3
因为生成器结束了,所以抛出错误
StopIteration
"""

使用 greenlet 实现协程(不推荐)

from greenlet import greenlet

def func1():
	print(1)			# 2
	gr2.switch()		# 3 切换到 func2 函数
	print(2)			# 6
	gr2.switch()		# 7 切换到 func2 函数,从上次执行的位置继续向后执行

def func2():
	print(3)			# 4
	gr1.switch()		# 5 切换到 func1 函数,从上次执行的位置继续向后执行
	print(4)			# 8

gr1 = greenlet(func1)
gr2 = greenlet(func2)
gr1.switch()		# 1 开始执行 func1 函数

使用 gevent 进行协程操作

基于 greenlet 的协程处理方式,使用猴子补丁将较常用的会产生阻塞的指令替换为 gevent 能够识别的指令,碰到后自动切换到其他协程任务。缺点在于猴子补丁可能会对某些库或函数不支持。

简单创建和执行协程任务

import time, gevent
from gevent import monkey

# 猴子补丁,将所有的会产生阻塞的指令替换为gevent能够识别的指令,碰到后自动切换其他的协程任务
monkey.patch_all()

def a():  # 任务A
    for i in range(5):
        print('A' + str(i))
        time.sleep(0.3)

def b():  # 任务B
    for i in range(5):
        print('B' + str(i))
        time.sleep(0.3)

def c():  # 任务C
    for i in range(5):
        print('C' + str(i))
        time.sleep(0.3)

if __name__ == '__main__':
    start = time.time()
	# 创建协程任务对象
    g1 = gevent.spawn(a)
    g2 = gevent.spawn(b)
    g3 = gevent.spawn(c)
    # 阻塞主程序,等待协程程序执行完成
    g1.join()
    g2.join()
    g3.join()
    end = time.time()
    print(end - start)

基于 gevent 的 request 请求

因为 monkey 猴子不支持 requests ,所以只能使用 urllib.request

import urllib.request, gevent
from gevent import monkey

monkey.patch_all()

def download(url):
    resp = urllib.request.urlopen(url)
    content = resp.read()
    print(f'下载了{url}的数据,长度为 {len(content)}')

if __name__ == '__main__':
    urls = [
        'http://www.163.com',
        'http://www.qq.com',
        'http://www.baidu.com',
        'http://www.hn3j.com.cn'
    ]
    tasks=[]
    for url in urls:
        g = gevent.spawn(download, url)
        tasks.append(g)
    gevent.joinall(tasks)

asyncio 实现协程(被替代)

当 python 3.4 推出了 asyncio 模块后,可以在 IO 操作阻塞时自动切换到其他任务。

import asyncio

@asyncio.coroutine								# 装饰器表名是协程函数,协程函数是不能使用函数名加括号的方式直接调用执行的
def func1():
	print(1)
	yield from asyncio.sleep(2)					# 遇到 io 耗时操作,切换到 tasks 中的其他任务
	print(2)

@asyncio.coroutine
def func2():
	print(3)
	yield from asyncio.sleep(2)					# asyncio.sleep 方法可以模拟 io 异步等待
	print(4)

tasks = [										# 协程任务对象列表
	asyncio.ensure_future( func1() ),			# 将协程函数封装为一个协程对象
	asyncio.ensure_future( func2() )
]

loop = asyncio.get_event_loop()					# 创建循环执行对象
loop.run_until_complete(asyncio.wait(tasks))	# 循环执行协程任务对象

使用 asyncio async await 进行协程操作 (推荐)

python 3.5 推出了 async、await 语法,结合 asyncio 可以基于协程遇到 IO 请求自动化切换任务。这种写法更简洁易读,也是目前最常用的协程处理方式。

简单创建和执行异步函数

import asyncio

async def func():				# 由装饰器更改,表示此函数为协程函数
	await ayncio.sleep(2)		# 由 yield 更改,表示在此等待协程函数执行,在协程函数执行完成前可以执行其他任务

if __name__ == '__main__':
	g = func()		# g 是协程函数 func 的协程对象,func 并不真的执行
	asyncio.run(g)	# 执行协程函数

async 和 await

在定义函数时,如果在 def 之前添加 async ,则声明此函数为协程函数(不再使用装饰器)。这种函数执行得到的是一个协程对象(coroutine)。执行协程对象,必须使用 asyncio 模块的 run() 函数(或是循环执行对象的 run_until_complete() 方法)来执行。

await 代替了 yield,一般放在协程函数中,而不写在主程序内。await 后跟的是 awaitable 对象 ,作用为等待 awaitable 对象有了执行结果,再继续执行之后的代码。

import asyncio, time

async def func1():
    print('执行函数1')
    await asyncio.sleep(3)  # 将同步操作 time.sleep(3) 改为异步,并等待(await)
    print('执行函数1')

async def func2():
    print('执行函数2')
    await asyncio.sleep(2)
    print('执行函数2')

async def func3():
    print('执行函数3')
    await asyncio.sleep(4)
    print('执行函数3')

if __name__ == '__main__':
    t1 = time.time()
    f1 = func1()
    f2 = func2()
    f3 = func3()
    tasks = [f1, f2, f3]  # 将协程对象添加到任务列表
    # 启动协程任务列表
    asyncio.run(asyncio.wait(tasks))
    t2 = time.time()
    print(t2 - t1)

这样写比较简单,另为了方便阅读,一般采用这种写法:

import asyncio, time

async def func1():
    print('执行函数1')
    await asyncio.sleep(3)  # 将同步操作 time.sleep(3) 改为异步,并挂起(await)
    print('执行函数1')

async def func2():
    print('执行函数2')
    await asyncio.sleep(2)
    print('执行函数2')

async def func3():
    print('执行函数3')
    await asyncio.sleep(4)
    print('执行函数3')

async def main():
	# 将协程对象包装成任务对象
    tasks = [func1(), func2(), func3()]  
    await asyncio.wait(tasks)   # 等待任务完成

if __name__ == '__main__':
    t1 = time.time()
    asyncio.run(main())		# 执行协程对象
    t2 = time.time()
    print(t2 - t1)

awaitable 对象–可等待对象

协程中有一个概念,就是 awaitable 对象,即可等待对象。协程一般是执行到可等待对象时切换到其他的任务。有三类对象是可等待的:

  • coroutine : 协程,本质上就是一个函数,以生成器 yield 和 yield from (async 和 await)为基础
  • Task :任务,就是对协程函数进一步的封装
  • Future :一个“更底层”的概念,代表一个异步操作的最终结果。因为异步操作一般用于耗时操作,不会立刻得到结果,会在“将来”得到异步运行结果,所以命名为 Future。

三者的关系,coroutine 可以自动封装成 task ,而 Task 是 Future 的子类。通常使用的是任务,其他两个很少用到(或由任务代替)。

在使用 wait 或 gather 方法添加任务时,有时候可以直接使用协程对象(coroutine),而不是将协程函数封装成任务。两者最主要的区别在于创建任务时会直接添加到循环,而使用协程对象时只在执行时才封装成为任务添加到循环。

另外,任务是有状态的,还有一些方法可以使用,例如可以使用 Task.cancel() 方法取消任务,这会触发 CancelledError 异常,使用 cancelled() 检查是否取消。

多协程任务的执行

多个协程任务执行时,一般会使用 gatherwait 方法来收集这些协程任务(方便管理),表示这些协程任务需要并发执行。因为这两个方法实际上是生成了一个 awaitable 对象,所以需要由 asyncio.run() 方法执行。gather() 方法和 wait() 方法的区别在于:

  • wait 方法使用一个集合来保存创建的任务实例,因为集合是无序的,所以任务不是顺序执行。而 gather 使用的是列表。
  • wait 会返回一个元组,包含两个集合,done 和 pending,即已完成的任务和超时未完成的任务;gather 返回所有已完成任务的 result 列表,可以直接调用获取结果。
  • wait 的参数是一个可迭代对象,内容是各任务,gather 的参数是若干个任务(即需要将任务集合拆包)
  • gather 必须等待所有任务结束,wait 带有状态,即使任务没有结束也记录
async def main():
	results = await asyncio.gather(tasks)
	for res in results:
		print(res)

asyncio.run(main())

这两个方法可以设置 timeout 参数,表示超时时间。

事件循环

上几个示例使用了 asyncio.run() 方法来执行协程任务,此方法是将事件循环进行了重新封装,使用起来更加简洁。但是此方法只在 python 3.7 以后版本支持,会创建一个新的事件循环并在执行结束后关闭,在关闭前不能再次创建循环,即不能再次运行。此方法是一个高层API,兼容性没有底层的事件循环好。

事件循环就是在一段死循环中,检测各任务的状态,将已完成的任务移出任务列表,检测等待的任务是否可以结束等待转为就绪,执行状态为就绪的任务代码。执行任务如果碰到异步等待时,将此任务转为等待状态,并停止执行代码继续循环检测,直到所有任务移出任务列表(即所有任务均执行完成)。

事件循环的使用

事件循环简单的使用方式为:

import asyncio

# 创建或获取一个事件循环对象
loop = asyncio.get_event_loop()
# 创建任务列表
tasks = [func() for _ in range(5)]
# 将任务列表中的任务放入循环并开始执行
loop.run_until_complete(asyncio.gather(*tasks))		

如果要用到多事件循环:

import asyncio

loop = asyncio.get_event_loop()		# 创建或获取一个事件循环对象,如果有则获取,如果没有则创建
loop.run_until_complete(asyncio.gather(*tasks))		# 将任务列表中的任务放入循环并开始执行
loop = asyncio.get_running_loop()	# 获取当前正在执行的循环对象,如果没有则会报错,python3.7 新增加

loop1 = asyncio.new_event_loop()	# 创建一个新的事件循环对象
asyncio.set_event_loop(loop1)		# 让某个循环对象成为当前线程的循环(只有一个循环在执行的情况下此步骤可以省略)
loop1.run_until_complete(asyncio.gather(*tasks))		# 将任务列表中的任务放入新循环并开始执行

一些其他的事件循环方法:

loop.stop()		# 停止事件循环
loop.close()	# 关闭事件循环,关闭后会释放资源,不可再次执行循环
loop.is_running()		# 判断事件是否在循环
loop.is_closed()		# 判断事件是否关闭
事件循环中的任务

事件循环中最常用到的就是任务,可以通过一些方法创建任务:

task = asyncio.ensure_future(coro())	# 由协程对象创建任务
task = asyncio.create_task(coro())		# 3.7 新添加此方法
# 也可以在创建任务时设置任务名称
task = asyncio.create_task(coro(), name='任务1')
# 也可以使用循环对象来创建任务
fut = loop.create_future()		# 创建一个空的(即没有执行内容)future对象
task = loop.create_task(coro())
# await fut	会造成死循环,因为 fut 是空的,永远没有执行结果
# 可以手动设置任务对象的结果,如果是在循环中添加的,则 await fut 就获得了执行结果
fut.set_result('666')

如果需要,也可以使用一些方法获取循环中的任务:

# 返回循环 loop 中,当前正在运行的任务,如果没有则返回 None。
# 如果没有参数 loop 或为 None ,则默认为当前执行的循环
task = asyncio.current_task(loop)	
# 获取全部的任务列表,参数的使用同 current_task 方法
tasks = asyncio.all_tasks(loop)

对于执行完成的任务,可以使用 result 方法返回执行结果 (return 的结果),也可以通过定义回调函数来使用返回的结果

# result 方法
for task in tasks:
	print(task.result())

# 回调函数
def callback(future):			# 回调函数
	print(future.result())

task.add_done_callback(callback)		# 绑定回调函数

需要注意的是,创建任务的同时就会将协程对象添加到循环,所以创建任务的方法可以指定添加到哪个循环,不指定则添加到当前循环。

import asyncio

async def func():
	print(1)
	await asyncio.sleep(1print(2)
	return '结束'

async def main():
	print('main开始')
	# 创建任务对象,并将协程对象添加到事件循环中
	task1 = asyncio.create_task( func() )
	task2 = asyncio.create_task( func() )
	print('任务添加结束')
	# 程序顺序执行到这里,碰到 await 时才会切换到事件循环中的其他任务
	ret1 = await task1
	ret2 = await task2
	print(ret1, ret2)

asyncio.run( main() )

上例中,先将 main 的协程对象添加到循环中,然后执行,执行过程中添加了2个任务,然后碰到了 await ,切换到循环中的其他任务继续执行,直到 await 的可等待对象执行完毕后继续。也就是说循环中有3个任务,main 任务因为调用了其他的任务,且要等待其他任务执行完成,所以实际上是处于同步状态。而因为创建了任务的同时会将任务添加到事件循环,所以如果创建任务的时候没有创建事件循环则会报错。

总结协程任务的几种简单执行方式

import asyncio

async def func():			# 创建协程函数
	await asyncio.sleep(1)

tasks = [func() for _ in range(5)]		# 任务列表
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
# 或直接使用 asyncio.run(asyncio.wait(tasks))

需注意的是3.11 以上版本种,前两种方式都不支持了,使用下面这种做法

import asyncio

async def func():			# 创建协程函数
	await asyncio.sleep(1)

async def main():
	tasks = [asyncio.create_task(func()) for _ in range(5)]		# 任务列表
	await asyncio.wait(tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
# asyncio.run(main()) 也可以

还有些地方使用的不是 create_task() 方法创建 Task 对象,而是使用 ensure_future() 方法将协程函数封装为 Future 对象。这种方法是在3.6以前的版本使用,3.7以后有了 Task 类(Future的子类)后,推荐使用封装为 Task 的方式。

另外关于 asyncio.create_task() 方法和 loop.create_task() 方法的区别,前一种方法会创建一个新的 loop,而后一种方法是 loop 对象的方法,使用的是现有的 loop。在某些需要指定 loop 的情况下,前一种方法是无法获取到 loop 对象的。

关于使用 asyncio.run() 还是使用 loop.run_until_complete(),前者是将后者和一些其他方法(例如资源回收等)封装起来的,所以能节省很多工作。但是因为 windows 对协程的支持不太好,所以推荐使用后者,否则少数环境可能会报错。MacOS 和 Linux 则无所谓,使用前者会更方便。

预处理

在使用 asyncio.create_task() 方法创建任务时,会运行协程函数内部的程序,直到遇到 await 等协程输出

import asyncio

async def func():
    print('准备执行任务')
    await asyncio.sleep(1)
    print('任务执行完成')

async def main():
    task = asyncio.create_task(func())

asyncio.run(main())
"""
并没有使用 asyncio.wait() 方法来添加执行协程任务,但是从输出结果来看,执行了 func 函数到 await 处停止。
这是因为创建协程任务时会对其进行预处理。
"""

await 关键字

await 关键字起的作用有:

  • 在一个异步函数中调度运行另一个异步函数
  • 获取对应任务的返回值
  • 阻塞当前线程,等待调度的异步函数执行结束
  • 只能调度可等待对象(协程对象、task对象、future对象等),可以使用 create_task() 方法创建单个任务对象,也可以使用 wait()gather() 方法创建含多个任务的任务对象

总之,如果调用的函数是一个 async 的异步函数,或该函数返回的是 task 对象或 future 对象,则调用时需要添加 await 关键字

对协程任务执行的限制

对于简单任务来说,尤其是对于爬虫等简单IO异步任务,协程的速度是最快的,比多线程、多进行要快的多。任务量大的情况下,很容易短时间内发出大量的请求,造成服务器拥塞甚至崩溃。此时可以对协程任务进行限制,设置协程池最大数量。

import time
import asyncio

sem = asyncio.Semaphore(3)  	# 设置协程最大并发数为3

async def coroutine_task():		# 协程任务
    async with sem:				# 使用协程任务池 sem
        print('协程任务开始')
        await asyncio.sleep(3)  # 异步等待3秒,模拟协程任务
        print(f"协程任务完成,时间是{time.strftime('%H:%M:%S', time.localtime())}")


async def main():		
    tasks = [asyncio.create_task(coroutine_task()) for _ in range(10)]		
    await asyncio.wait(tasks)

print(f'开始执行程序,时间为:{time.strftime("%H:%M:%S", time.localtime())}')
# loop = asyncio.get_event_loop()
# loop.run_until_complete(main())
asyncio.run(main())

需要注意的是,python 3.9 版本尽管能够使用 asyncio.run(main()) 来执行任务,但是使用 Semaphore 任务池对象会报错,而只能使用获取循环事件执行任务的方式执行。3.10以上两种方式都可以执行,

异步的 request 请求,异步文件读写

因为 requests 模块是同步的,所以需要用到时可以使用 aiohttp 模块进行异步请求。使用 pip install aiohttp 安装 aiohttp 模块。另文件IO操作也可以异步进行,使用 aiofiles 模块,使用 pip install aiofiles 进行安装。

import asyncio, aiohttp,aiofiles

urls = [
    'http://kr.shanghai-jiuxin.com/file/2022/0610/365b41c9517553b9309ad66b7b1852c9.jpg',
    'http://kr.shanghai-jiuxin.com/file/2022/0515/c0840490c8f14058176348c4b5e3f48e.jpg',
    'http://kr.shanghai-jiuxin.com/file/2022/0428/ffdbca68f1e9fa3bec33e2757aaaa43a.jpg'
]

async def aiodownload(session, url):
    name = url.rsplit('/', 1)[1]		# 从右侧开始拆分字符,区别于 split 的从左侧开始拆分
    # 异步发送请求
    async with session.get(url) as resp:  # 发送异步请求
		# 读取内容是异步的,挂起等待返回 resp 再执行
		cont = await resp.content.read()  # 读取 resp 的内容,文本使用 resp.text(),json 使用 resp.json()
		async with aiofiles.async_open(name, mode='wb') as f:     # 使用 aiofiles 库异步执行文件写入操作
			await f.write(cont)         # 挂起等待异步文件f写入完成

async def main():
	async with aiohttp.ClientSession() as session:  # 建立异步的 session 对象,写在这里可以使所有任务使用同一个 session
    	tasks = [asyncio.create_task(aiodownload(session, url)) for url in urls]		# 使用生成器创建任务列表
    	await asyncio.wait(tasks)   	# 异步执行任务并等待

if __name__ == '__main__':
    # asyncio.run(main())   会自动关闭循环,所以在 async with 结束时再关闭会报错,但是程序执行没有问题
    # 创建一个循环执行对象
    loop = asyncio.new_event_loop()
    # 设置对象为当前事件循环
    asyncio.set_event_loop(loop)
    # 执行事件循环直到完成
    loop.run_until_complete(main())

返回的 response 对象有多种读取方式,例如使用 response.text() 读取文本内容(字符串),response.json() 获取 json 数据,使用 response.read()response.content.read() 都能读取二进制码,区别是前一种是流式处理,后一种是一次性读取。

协程和线/进程池的结合,异步使用同步函数

如果需要将同步函数在事件循环中异步使用,可以使用 loop.run_in_executor() 方法。

import time, asyncio, concurrent.futures

def func():
    # 某个耗时操作
    time.sleep(2)
    return 'Over!'

async def main():
    loop = asyncio.get_running_loop()

    # 1. Run in the default loop's executor (默认为 ThreadPoolExecutor)
    # 第一步:内部会先调用 ThreadPoolExecutor 的 submit 方法去线程池中申请一个线程去执行 func 函数,并返回一个 concurrent.futures.Future 对象
    # 第二部:调用 asyncio.wrap_future 将 concurrent.futures.Future 对象包装为 asyncio.Future 对象
    # 因为 concurrent.futures.Future 对象不支持 await 语法,所以需要包装为 asyncio.Future 对象,才能使用。
    fut = loop.run_in_executor(None, func)		# 此方法可以在其他的进/线程池中执行循环
    result = await fut
    print('default thread pool', result)
	
	# loop.run_in_executor 第一个参数如果是None,则新创建线程池,如果需要使用已有的线程池或进程池则将对象传入

    # 2. Run in a custom thread pool
    # with concurrent.futures.ThreadPoolExecutor() as pool:
    #     result = await loop.run_in_executor(pool, func)	
    #     print('custom thread pool',result)

    # 3. Run in a custom process pool
    # with concurrent.futures.ProcessPoolExecutor() as pool:
    #     result = await loop.run_in_executor(pool, func)
    #     print('custom process pool', result)

asyncio.run(main())

通常用于需要协程操作但是不支持协程的模块,例如 requests。但是 loop.run_in_executor() 方法并不支持关键字传参,例如增加请求头等。所以需要使用偏函数来辅助传递参数。

import asyncio, requests
from functools import partial

async def download(loop, url):
    print('开始下载', url)
    # requests 模块默认不支持异步操作,所以使用线程池来配合实现异步
    resp = await loop.run_in_executor(None, partial(requests.get, url, headers=headers))	# 使用偏函数传值
    print('下载完成')
    # 保存图片,write 方法也不支持异步操作,所以也使用线程池
    file_name = url.rsplit('/', 1)[1]
    with open(file_name, mode='wb') as f:
        await loop.run_in_executor(None, f.write, resp.content)	# 不使用关键字传参,则可以直接使用

if __name__ == '__main__':
    url_list = [...]
    loop = asyncio.get_event_loop()
    tasks = [loop.create_task(download(loop, url)) for url in url_list]
    loop.run_until_complete(asyncio.wait(tasks))

这种方法比纯协程需要消耗更多的资源,所以如非必要不推荐使用。

对于协程的参考文章

python协程(1): 基本介绍及yield实现协程
python协程(2): asyncio的核心概念与基本架构(含任务创建执行标准用法)
python协程(3): asyncio的EventLoop以及Future详解
python协程(4): asyncio结合多线程解决阻塞问题以及timer模拟

异步迭代器

import asyncio

class Reader:
	def __init__(self):
		self.count = 0
		
	async def read_line(self):
		self.count += 1
		if self.count == 100:
			return None
		return self.count
	
	def __aiter__(self):		# 异步迭代器
		return self
	
	async def __anext__(self):		# 异步获取迭代器下一个值
		value = await self.read_line()
		if value is None:
			raise StopAsyncIteration
		return value

async def main():				
	async for i in Reader():		# 异步执行 for 循环
		print(i)

asyncio.run(main())

异步生成器

import asyncio

async def func():				# 异步生成器
	for i in range(100):
		yield i

async def main():		
	async for i in func():		# 异步 for 循环
		print(i)

绑定回调函数

import asyncio

async def work(content):
	print('信息内容:',content)
	return f'返回值为:{content}'

loop = asyncio.get_event_loop()
task = loop.create_task(work('测试内容'))

# 通常可以使用  res = loop.run_until_complete(task),通过 res 获取结果。也可以使用回调函数,使用 task.result() 
# 使用回调函数,当前任务执行完成后自动调用回调函数输出结果
def callback(task_obj):
	print('回调函数输出:',task_obj.result())

# 将回调函数添加到任务中,任务对象将作为回调函数的参数传入
task.add_done_callback(callback)

loop.run_until_complete(task)

异步上下文管理器

class AsyncContextManager:
	def __init__(self, conn=None):
		print(1)
		self.conn = conn
	
	async def do_something(self):		# 模拟异步数据库操作
		print(3)
		return '模拟数据库异步操作'

	async def __aenter__(self):		# 异步上下文管理器创建时执行
		print(2)
		self.conn = await asyncio.sleep(1, result='db_obj')		# 模拟数据库连接耗时
		return self

	async def __aexit__(self, exc_type, exc_val, exc_tb):		# 异步上下文管理器执行完成后执行
		print(4)
		await asyncio.sleep(1)			# 模拟关闭数据库连接

async def main():
	async with AsyncContextManager() as fp:		# 异步上下文管理器
		result = await fp.dosomething()
		print(result)

多级并发

有时候需要使用到多级的协程操作,例如在爬取页面1时获取数据作为参数来爬取页面2。希望在获取页面1的数据时(并不是所有任务都执行完,而是只要有个别任务完成)就开始爬取页面2的任务。或者是并发爬取页面后,只要有数据返回就并发存储数据**(不过不推荐多线程、异步进行保存操作,尽量完整保存过程防止数据出错)**。这就需要进行多级并发操作。

import asyncio, aiohttp

async def get_param(page, session):
	# 请求连接,获取参数
	async with 	session.get(url_param.format(page)) as response:
		content = await response.read()
		# 处理内容并获取第二级请求的参数
		params = parse_content(content)		# 具体内容省略,获取到了参数列表
		# 创建第二级并发任务
		tasks = [asyncio.create_task(get_result(param, session)) for param in params]
		await asyncio.wait(tasks)
		
async def main():
	# 创建请求池对象
	async aiohttp.ClientSession() as session:
		# 创建并执行任务,即第一级并发
		tasks = [asyncio.create_task(get_param(page, session)) for page in range(1, 11)]
		await asyncio.wait(tasks)

if __name__ == '__main__':
	asyncio.run(main())

uvloop

uvloop 是 linux 的一个第三方的对于 asyncio 的事件循环的替代方案,其效率要高于 asyncio 的事件循环。

pip install uvloop
import asyncio, uvloop
# 将 asyncio 的事件循环设置为 uvloop 的事件循环
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
# 正常写代码,可以使用 asynico 的代码

子进程

有时我们需要通过一个python进程来新建一个控制台用于运行另一段程序,原来的python进行就是父进程,新开的控制台属于子进程。

subprocess 模块允许我们启动一个新进程,并连接到它们的输入/输出/错误管道,从而获取返回值。最常用的是 run()call()Popen() 方法

subprocess.CompletedProcess 类

CompletedProcess 类表示的是一个已结束进程的状态信息,它所包含的属性如下:

  • args: 用于加载该进程的参数,这可能是一个列表或一个字符串
  • returncode: 子进程的退出状态码。通常情况下,退出状态码为0则表示进程成功运行了;一个负值-N表示这个子进程被信号N终止了
  • stdout: 从子进程捕获的stdout。这通常是一个字节序列,如果run()函数被调用时指定universal_newlines=True,则该属性值是一个字符串。如果run()函数被调用时指定stderr=subprocess.STDOUT,那么stdout和stderr将会被整合到这一个属性中,且stderr将会为None
  • stderr: 从子进程捕获的stderr。它的值与stdout一样,是一个字节序列或一个字符串。如果stderr灭有被捕获的话,它的值就为None
  • check_returncode(): 如果returncode是一个非0值,则该方法会抛出一个CalledProcessError异常。

subprocess.run() 和 subprocess.call()

subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=False)
subprocess.call(args, *, stdin=None, stdout=None, stderr=None, shell=False, timeout=None)

常用的参数有:

  • args : 要执行的shell命令,默认应该是一个字符串序列,如[‘df’, ‘-Th’]或(‘df’, ‘-Th’),也可以是一个字符串,如’df -Th’,但是此时需要把shell参数的值置为True
  • stdin、stdout 和 stderr : 子进程的标准输入、输出和错误。其值可以是 subprocess.PIPE、subprocess.DEVNULL、一个已经存在的文件描述符、已经打开的文件对象或者 None。subprocess.PIPE 表示为子进程创建新的管道。subprocess.DEVNULL 表示使用 os.devnull。默认使用的是 None,表示什么都不做。另外,stderr 可以合并到 stdout 里一起输出。
  • shell : 如果该参数为 True,将通过操作系统的 shell 执行指定的命令。可以访问某些shell的特性,如管道、文件名通配符、环境变量扩展功能。
  • timeout :设置命令超时时间。如果命令执行时间超时,子进程将被杀死,并弹出 TimeoutExpired 异常。
  • check :如果该参数设置为 True,并且进程退出状态码不是 0,则弹出 CalledProcessError 异常。
  • universal_newlines : 该参数影响的是输入与输出的数据格式,比如它的值默认为False,此时stdout和stderr的输出是字节序列;当该参数的值设置为True时,stdout和stderr的输出是字符串。
  • encoding : 输出编码,默认字节码

run 方法返回 CompletedProcess 实例,而 call 方法只返回命令执行状态(即 CompletedProcess 实例中的 returncode)。需注意的是,这两个方法默认不捕获标准输出和标准错误,如果需要捕获需要传递PIPE给stdout和stderr参数。另部分命令是基于系统 shell 的,例如 dir 等,使用此类命令必须带 shell=True 参数。

例如:

import subprocess

def runcmd(command):
    ret = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1 , universal_newlines=True)
    if ret.returncode == 0:
        print("success:", ret)
    else:
        print("error:", ret)

runcmd(["dir", "/b"])  # 序列参数
runcmd("exit 1")  # 字符串参数

输出结果为:

success: CompletedProcess(args=['dir', '/b'], returncode=0, stdout='test.py\n', stderr='')
error: CompletedProcess(args='exit 1', returncode=1, stdout='', stderr='')

subprocess.Popen 类

Popen 类由 Popen() 方法创建,该类用于在一个新的进程中执行一个子程序。run()call() 等函数都是基于subprocess.Popen类实现的,通过使用这些被封装后的高级函数可以很方面的完成一些常见的需求。由于subprocess模块底层的进程创建和管理是由Popen类来处理的,因此,当我们无法通过上面哪些高级函数来实现一些不太常见的功能时就可以通过subprocess.Popen类提供的灵活的api来完成。

构造函数和常用参数说明

class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, 
preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=False, 
startupinfo=None, creationflags=0,restore_signals=True, start_new_session=False, pass_fds=(),
*, encoding=None, errors=None)

参数说明:

  • args:shell命令,可以是字符串或者序列类型(如:list,元组)。当该参数的值是一个字符串时,该命令的解释过程是与平台相关的,因此通常建议将args参数作为一个序列传递。
  • bufsize:缓冲区大小。当创建标准流的管道对象时使用,默认-1。
    • 0:不使用缓冲区
    • 1:表示行缓冲,仅当universal_newlines=True时可用,也就是文本模式
    • 正数:表示缓冲区大小
    • 负数:表示使用系统默认的缓冲区大小。
  • stdin, stdout, stderr:分别表示程序的标准输入、输出、错误句柄。
  • preexec_fn:只在 Unix 平台下有效,用于指定一个可执行对象(callable object),它将在子进程运行之前被调用
  • shell:如果该参数为 True,将通过操作系统的 shell 执行指定的命令,建议将args参数作为一个字符串传递而不要作为一个序列传递
  • cwd:如果该参数值不是None,则该函数将会在执行这个子进程之前改变当前工作目录。
  • env:用于指定子进程的环境变量。如果 env = None,子进程的环境变量将从父进程中继承。如果不为空,则其值必须是一个映射对象
  • close_fds: 如果该参数的值为True,则除了0,1和2之外的所有文件描述符都将会在子进程执行之前被关闭
  • universal_newlines: 如果该参数值为True,则该文件对象的stdin,stdout和stderr将会作为文本流被打开,否则他们将会被作为二进制流被打开
  • encoding : 使用的编码
  • startupinfo和creationflags: 这两个参数只在Windows下有效,它们将被传递给底层的CreateProcess()函数,用于设置子进程的一些属性,如主窗口的外观,进程优先级等。例如 creationflags=subprocess.CREATE_NEW_CONSOLE 则新开控制台执行子程序

简单的例子:

>>> import subprocess
>>> p = subprocess.Popen('ls -l', shell=True)

Popen 对象属性和方法

  • poll(): 检查进程是否终止,如果终止返回状态码 returncode,否则返回 None。
  • wait(timeout=None): 等待子进程终止。如果超时,则抛出TimeoutExpired异常。注意: 这将使用时死锁stdout=PIPE或者stderr=PIPE和子进程结果输出到管道,使它阻止等待os缓冲器接受更多数据,使用 Popen.communicate() 可避免这种情况
  • communicate(input,timeout): 和子进程交互,比如发送数据到stdin,从stdout和stderr读取数据,直到到达文件末尾。
  • send_signal(singnal): 发送信号到子进程 。
  • terminate(): 停止子进程,也就是发送SIGTERM信号到子进程。
  • kill(): 杀死子进程。发送 SIGKILL 信号到子进程。
  • args(): 传递给Popen的参数,可以是列表或者字符串
  • pid : 返回子进程ID ,PS: (如果将shell=True,则是生成shell的进程ID)
  • returncode : 返回子进程的状态码.

关于 communicate() 方法的一些说明:

  • 该方法中的可选参数 input 应该是将被发送给子进程的数据,或者如没有数据发送给子进程,该参数应该是None。input参数的数据类型必须是字节串,如果universal_newlines参数值为True,则input参数的数据类型必须是字符串。

  • 该方法返回一个元组(stdout_data, stderr_data),这些数据将会是字节串或字符串(如果universal_newlines的值为True)。

  • 如果在timeout指定的秒数后该进程还没有结束,将会抛出一个TimeoutExpired异常。捕获这个异常,然后重新尝试通信不会丢失任何输出的数据。但是超时之后子进程并没有被杀死,为了合理的清除相应的内容,一个好的应用应该手动杀死这个子进程来结束通信。

  • 需要注意的是,这里读取的数据是缓冲在内存中的,所以,如果数据大小非常大或者是无限的,就不应该使用这个方法

简单实例

实例1,简单使用,获取输出信息:

import subprocess

def cmd(command):
    subp = subprocess.Popen(command,shell=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,encoding="utf-8")
    subp.wait(2)
    if subp.poll() == 0:
        print(subp.communicate()[0])		# 可以使用 subp.stdout.read() 获取输出结果,两者不能同时使用
    else:
        print("失败")	# 可以使用 supb.communicate()[1] 获取错误信息

cmd("java -version")
cmd("exit 1")

实例1结果:

java version "1.8.0_31"
Java(TM) SE Runtime Environment (build 1.8.0_31-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.31-b07, mixed mode)

失败

实例2,输入信息到子进程(不是带参数运行子进程):

import subprocess

def cmd(command):
    subp = subprocess.Popen('python', shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
    subp.stdin.write(command)
    out, err = subp.communicate()
    print(out)
    print(err)

cmd('print(1) \n')
cmd('print(2) \n')

实例3,使用 communicate() 方法输入信息到子进程:

import subprocess

def cmd(command):
    subp = subprocess.Popen('python', shell=True, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
    out, err = subp.communicate(input=command)
    print(out)
    print(err)

cmd('print(1) \n')
cmd('print(2) \n')

实例4,将一个子进程的输出作为另一个子进程的输入,类似于 linux 中 df -Th | grep data 命令里管道的功能:

>>> p1 = subprocess.Popen(['df', '-Th'], stdout=subprocess.PIPE)
>>> p2 = subprocess.Popen(['grep', 'data'], stdin=p1.stdout, stdout=subprocess.PIPE)
>>> out,err = p2.communicate()
>>> print(out)
/dev/vdb1      ext4      493G  4.8G  463G   2% /data
/dev/vdd1      ext4     1008G  420G  537G  44% /data1
/dev/vde1      ext4      985G  503G  432G  54% /data2

>>> print(err)
None

若进程超时没有终止,捕获TimeoutExpired异常重新通信不会丢失任何输出结果。如超时,子进程不会自动终止,因此为了清理残留子进程,可以这么做

proc = subprocess.Popen(...)
try:
    outs, errs = proc.communicate(timeout=15)
except TimeoutExpired:
    proc.kill()
    outs, errs = proc.communicate()

如果需要实时获取输出信息,可以参考

Python 通过subprocess运行代码,并实时获得该代码的窗口输出(如print输出、神经网络模型训练和推理相关实时输出等)

记录代码:

import subprocess
import threading

class CMDProcess(threading.Thread):
    '''
        执行CMD命令行的 进程
    '''
    def __init__(self, args,callback):
        threading.Thread.__init__(self)		# 创建多线程,继承自 Thread 类
        self.args = args
        self.callback=callback
        
    def run(self):
        self.proc = subprocess.Popen(self.args,bufsize=0,shell = False,stdout=subprocess.PIPE)
        
        while self.proc.poll() is None:
            line = self.proc.stdout.readline()
            line = line.decode("utf8") 
            if(self.callback):
                self.callback(line)


def getSubInfo(text):
    print("子进程测试代码实时输出内容=>" + text)

def writeTxt(text):
    with open("res.txt", "a", encoding="utf-8") as f:
        f.write(text.strip() + "\n")  # 先去除每一行末尾的制表符和换行符,然后再加上换行符,使写入文件中的内容不会有空行

def main():

    cmd = [
            'python',
            '-u', # 注意,这里必须带上-u
            'testSub.py'			# 执行的python脚本,在同一目录下
            ]			
    print("子进程测试代码的运行命令:", ' '.join(cmd))
    testProcess = CMDProcess(cmd,getSubInfo )	# 参数是执行命令及获取输出的回调函数,如输出日志文本使用 writeTxt 函数
    testProcess.start()

if __name__ == '__main__':
	main()

一些其他方法

  • subprocess.check_call() : 执行指定的命令,如果执行成功则返回状态码,否则抛出异常。其功能等价于 subprocess.run(..., check=True)
  • subprocess.check_output() : 执行指定的命令,如果执行状态码为0则返回命令执行结果,否则抛出异常。
  • subprocess.getoutput(cmd) : 接收字符串格式的命令,执行命令并返回执行结果,其功能类似于 os.popen(cmd).read()commands.getoutput(cmd)
  • subprocess.getstatusoutput(cmd) : 执行cmd命令,返回一个元组(命令执行状态, 命令执行结果输出),其功能类似于 commands.getstatusoutput()

说明:

  1. 在Python 3.5之后的版本中,官方文档中提倡通过subprocess.run()函数替代其他函数来使用subproccess模块的功能;
  2. 在Python 3.5之前的版本中,可以通过subprocess.call(),subprocess.getoutput()等函数来使用subprocess模块的功能;
  3. subprocess.run()、subprocess.call()、subprocess.check_call()和subprocess.check_output()都是通过对subprocess.Popen的封装来实现的高级函数,因此如果我们需要更复杂功能时,可以通过subprocess.Popen来完成。
  4. subprocess.getoutput()和subprocess.getstatusoutput()函数是来自Python 2.x的commands模块的两个遗留函数。它们隐式的调用系统shell,并且不保证其他函数所具有的安全性和异常处理的一致性。另外,它们从Python 3.3.4开始才支持Windows平台。

一些注意点:

  • 在子进程中运行 django 服务,一些 django 的信息甚至不是通过 stdout 就直接输出至父进程控制台。如果使用 input()while True 循环等待输入,必须有一些其他动作行为才能够启动 django 服务。这时不能使用 stdout 且需要 creationflags=subprocess.CREATE_NEW_CONSOLE,如不想新开控制台可以使用 creationflags=subprocess.CREATE_NO_WINDOW。
  • Popen 对象的 terminate()kill() 方法只是杀死直接创建的子进程,不会结束派生子进程。linux 下可以参考 python 杀死子进程_python subprocess 杀掉全部派生的子进程方法 这个文章。windows 下可以使用 psutil.Process(pid).children() 来获取所有子进程的派生进程,逐个结束。需注意如果需要获取所有子孙进程需要遍历。
  • 可以通过 send_signal(signal) 这个方法发送信号至子进程,例如发送 SIGTERM (在windows系统中)相当于执行了 terminate() 方法。也可以发送 CTRL_C_EVENT 和 CTRL_BREAK_EVENT 信号。 (实际测试中 CTRL_C_EVENT 不成功,CTRL_BREAK_EVENT未测试)
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值