Python 并发编程

274 阅读9分钟

进程与线程

一个程序至少有一个进程,一个进程至少有一个线程,最终是线程在工作。

  • 进程:计算机资源分配的最小单元(进程为线程提供资源)
  • 线程:计算机中可以被CPU调度的的最小单元(真正的工作)
  • 一个进程中可以有多个线程,同一个进程中的线程可以共享此进程中的资源

多线程:以下载抖音视频为例

import time
import requests
import threading

url_list = [
    ('1.mp4', 'https://www.xxxxxx.......'), 
    ('2.mp4', 'https://www.xxxxxx.......'),
    ('3.mp4', 'https://www.xxxxxx.......')
]

def task(file_name, video_urlk):
    res = requests.get(video_url)
    with open(file_name, mode='wb') as f:
        f.write(res.content)
    print(time.time())

print(time.time())
for name, url in url_list:
    # 创建线程,让每个线程都去执行task函数
    t = threading.Thread(target=task, atgs=(name, url))
    t.start 

t = threading.Thread(target=函数名, args=(arg1, arg2, ...))创建了一个线程对象,执行的任务是target参数的值(一个函数),args是执行任务所需的参数。t.start()创建的线程开始工作。

多进程

import time
import requests
import multiprocessing

url_list = [
    ('1.mp4', 'https://www.xxxxxx.......'), 
    ('2.mp4', 'https://www.xxxxxx.......'),
    ('3.mp4', 'https://www.xxxxxx.......')
]

def task(file_name, video_urlk):
    res = requests.get(video_url)
    with open(file_name, mode='wb') as f:
        f.write(res.content)
    print(time.time())

if __name__=="__main__":
    print(time.time())
    for name, url in url_list:
        p = multiprocessing.Process(target=task, args=(name, url))
        p.start()

p = multiprocessing.Process(target=函数名, args=(arg1, arg2, ...))创建一个进程,执行target传参的任务。注意创建进程之后,还会创建一个线程。p.start()开始执行进程。

注意Linux系统支持fork创建进程,Windows系统支持spawn,Mac既支持fork也支持spawn(Python3.8默认设置为spawn)。所以Windows下需要加上if __name__=="__main__".

多进程比多线程的开销要大

GIL锁

GIL,全局解释器锁,是CPython解释器特有的一个东西,让一个进程中同一时刻只能有一个线程被CPU调度

如果程序想利用计算机的多核优势,则应该选择多进程帮助开发;如果不利用计算机的多核优势,适合选择多线程开发。

常见程序开发中,计算操作需要使用CPU多核优势,IO操作不需要利用CPU多核优势,所以:

  • 计算密集型任务,用多进程,例如大量的数据计算
  • IO密集型任务,用多线程,例如文件读写,网络数据传输等

多线程开发

import threading

def task(arg):
    pass 

# 创建一个Thread的实例,并封装线程被CPU调度时应该执行的任务与相关参数
# threading.Thread创建的是子线程,主线程负责从上到下执行代码
t = threading.Thread(target=task, args=('xxx', ))
# 线程准备就绪(等待被CPU调度),代码继续向下执行
t.start()

print("继续执行...")  # 主线程执行完所有代码,不结束;等待子线程

threading.Thread类有两种方法可以指定活动:将可调用对象传递给构造函数,或者重写子类中的run()方法。子类中不应重写任何其他方法(构造函数除外)。换句话说,只重写这个类的__init__()run()方法。

当线程对象一旦被创建,其活动必须通过调用线程的 start() 方法开始。 这会在独立的控制线程中发起调用 run() 方法。

一旦线程活动开始,该线程会被认为是 '存活的' 。当它的 run() 方法终结了(不管是正常的还是抛出未被处理的异常),就不是'存活的'。 is_alive() 方法用于检查线程是否存活。

其他线程可以调用一个线程的 join() 方法。这会阻塞调用该方法的线程,直到被调用 join() 方法的线程终结。

线程有名字。名字可以传递给构造函数,也可以通过 name 属性读取或者修改。

如果 run() 方法引发了异常,则会调用 threading.excepthook() 来处理它。 在默认情况下,threading.excepthook() 会静默地忽略 SystemExit

class threading.Thread(*group=None, target=None, name=None, args=(), kwargs={}, \*, daemon=None*)

  • group 应该为 None;为了日后扩展 ThreadGroup 类实现而保留。
  • target 是用于 run() 方法调用的可调用对象。默认是 None,表示不需要调用任何方法。
  • name 是线程名称。默认情况下,由 "Thread-N" 格式构成一个唯一的名称,其中 N 是小的十进制数。
  • args 是用于调用目标函数的参数元组。默认是 ()。
  • 如果不是 Nonedaemon 参数将显式地设置该线程是否为守护模式。 如果是 None (默认值),线程将继承当前线程的守护模式属性。守护线程会随着主线程的结束而消失,当没有存活的非守护线程时,整个Python程序才会退出。
  • 如果子类型重载了构造函数,它一定要确保在做任何事前,先发起调用基类构造器(Thread.__init__())。

线程的常见方法

  1. threading.Thread.start():当前线程准备就绪,等待CPU调度,但是具体时间由CPU来决定。它在一个线程里最多只能被调用一次。 它安排对象的 run() 方法在一个独立的控制线程中被调用。如果同一个线程对象中调用这个方法的次数大于一次,会抛出 RuntimeError 。
import threading

loop = 10000000
number = 0

def _add(count):
    global number
    for i in range(count):
        number += i

t = threading.Thread(target=_add, args=(loop, ))
t.start()

print(number)

这里输出number的值是不确定的,因为主线程和子线程之间来回切换,主线程运行print(number)时,无法确定子线程计算到了哪一步。

  1. threading.Thread.join(timeout=None):等待当前线程执行完毕后,再继续向下运行。这会阻塞调用这个方法的线程,直到被调用 join() 的线程终结 -- 不管是正常终结还是抛出未处理异常 -- 或者直到发生超时,超时选项是可选的。
import threading

loop = 10000000
number = 0

def _add(count):
    global number
    for i in range(count):
        number += i

t = threading.Thread(target=_add, args=(loop, ))
t.start()
t.join()  # 主线程等待中

print(number)

现在主线程输出的number的值是固定的,因为当主线程执行print(number)时,子线程已经完成了全部计算。

import threading

loop = 10000000
number = 0

def _add(count):
    global number
    for i in range(count):
        number += i
        
def _sub(count):
    global number
    for i in range(count):
        number -= i

t1 = threading.Thread(target=_add, args=(loop, ))
t2 = threading.Thread(target=_sub, args=(loop, ))
t1.start()
t2.start()

t1.join()  
t2.join()

print(number)

这里主线程最终输出的number仍然是不确定的,因为当t1、t2两个线程就绪之后就开始执行,两个线程对同一资源(number)操作导致number结果会出现混乱,例如当t2执行number -= i时,切换到t1执行了number += i,可是再切换回t2线程时,number没有同步更新,number -= i操作中的number不是执行了number += i后的number,所以最终number的值不确定。

  1. threading.Thread.run():代表线程活动的方法。你可以在子类型里重载这个方法。 标准的 run() 方法会对作为 target 参数传递给该对象构造器的可调用对象(如果存在)发起调用,并附带从 argskwargs 参数分别获取的位置和关键字参数。
  2. threading.Thread.is_alive():返回线程是否存活。当 run() 方法刚开始直到 run() 方法刚结束,这个方法返回 True 。模块函数 enumerate() 返回包含所有存活线程的列表。
  3. 继承threading.Thread,自定义线程类
import requests
import threading

class DouYinThread(threading.THread):

    def run(self):
        file_name, video_url = self._args
        res = requests.get(video_url)
        with open(file_name, mode='wb') as f:
            f.write(res.content)

url_list = [
    ('1.mp4', 'https://www.xxxxxx.......'), 
    ('2.mp4', 'https://www.xxxxxx.......'),
    ('3.mp4', 'https://www.xxxxxx.......')
]

for file_name, video_url in url_list:
    t = DouYinThread(args=(file_name, video_url))
    t.start()

线程安全

一个进程中可以有多个线程,且线程共享进程的所有资源。多个线程同时去操作一个资源,可能会存在数据混乱的情况。

import threading

lock = threading.Rlock()  # 创建线程锁

loop = 10000000
number = 0

def _add(count):
    lock.acquire()  # 申请锁,没有申请成功会等待其他线程释放锁
    global number
    for i in range(count):
        number += i
    lock.release()  # 释放锁
        
def _sub(count):
    lock.acquire()  # 申请锁
    global number
    for i in range(count):
        number -= i
    lock.release()  # 释放锁

t1 = threading.Thread(target=_add, args=(loop, ))
t2 = threading.Thread(target=_sub, args=(loop, ))
t1.start()
t2.start()

t1.join()  
t2.join()

print(number)

也可以通过上下文管理的机制获取/释放锁。

import threading

num = 0
lock = threading.Rlock()

def task():
    with lock:  # 基于上下文管理,内部制动执行acquire()和release()
        global num
        for i in range(10000000):
            num += 1
    print(num)
    
for i in range(2):
   t = threading.Thread(target=task)
   t.start()

有些数据类型是线程安全的,在操作它们的时候,它们内部自带锁。所以在操作它们的时候不需要去获取/释放锁。

线程锁

线程加锁一般有两种:Lock同步锁,Rlock递归锁。区别是Lock不支持锁的嵌套,但是Rlock支持;Lock的效率比Rlock高。

import threading
lock = threading.Rlock()

def func_A():
    with lock:
        do_something
        
def func_B():
    ...
    func_A()
    ...

def func_C():
    with lock:
        ...
        func_A()
        ...

如果上述的三个函数由三个不同的程序员开发,func_C中上锁,而func_C中引用的func_A在执行过程中也上了锁,这种情况就是锁的嵌套,只能使用递归锁Rlock

死锁

由于竞争资源或者彼此通信而造成的一种阻塞状态。

线程池

线程不是开的越多越好,开多了线程会导致系统性能降低,尤其是不能无限制地创建线程。

import time 
# 引入线程池
from concurrent.futures import ThreadPoolExecutor

def task(video_url):
    do_something
    
url_list = [f"www.xxx.com/?page={i}" for in in range(100)]  

# 创建了一个线程池,最多维护10个线程
pool = THreadPoolExecutor(10)

for url in url_list:
    # 在线程池提交一个任务。如果线程池中有空闲的线程,则分配一个线程去执行,执行完毕后再将线程交还线程池
   # 如果没有空闲线程,则等待其他线程执行完毕
    pool.submit(task, url)

pool.shutdown(True) :等待线程池的任务执行完毕后,再继续执行。

执行完任务,再干点别的事情。不如task执行下载,done将下载的数据写入本地。

def done(args):
    do_something

for url in url_list:
    # 线程池的函数先执行task,完毕后再执行done函数
    future = pool.submit(task, url)
    future.add_done_callback(done)

也可以统一获取全部线程池的结果

future_list = []

for url in url_list:
    future = pool,submit(task.url)
    future.append(future)
    
pool.shutdown(True)
for future in future_list:
    print(future.result())

单例模式

单例模式:每次实例化类的对象时,都是最开始创建的那个对象,不再重复创建对象。

传统方法