9. 并发编程

多线程

几乎所有的操作系统都支持同时运行多个任务,一个任务通常就是一个程序,每个运行中的程序就是一个进程
当一个程序运行时,内部可能包含多个顺序执行流,每个顺序执行流就是一个线程

线程的创建和启动

Python 主要通过两种方式来创建线程
  - 使用 threading 模块的 Thread 类的构造器创建线程
  - 使用 threading 模块的 Thread 类创建线程类

调用 Thread 类的构造器创建线程

_init_(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None)

group: 指定该线程所属的线程组,目前该参数还未实现,所以它只能设为 None
target: 执行该线程要调度的目标方法
args: 指定一个元组,以位置参数的形式为为 target 指定的函数传入参数,元组的第一个元素传给 target 函数的第一个参数,元组的第二元素传给 target 函数的第二个参数......以此类推
kwargs:指定一个字典,以关键字参数的形式为 target 指定的函数传入参数
daemon:指定所构建的线程是否为后台线程

启动顺序

1. 调用 Thread 类的构造器创建线程对象,在创建线程对象时,target 参数指定的函数将作为线程执行体
2. 调用线程对象的 start() 方法启动该线程
import threading


# threading.current_thread(): 它是 threading 模块的函数,该函数总是返回当前正在执行的线程对象
# getName(): 它是 Thread 类的实例方法,该方法总是返回调用它的线程名字

# 程序可以通过 setName(name) 方法为线程设置名字,也可以通过 getName()方法返回指定线程的名字,
# 这两个方法可通过 name 属性来代替
# 默认情况下,主线程的名字为 MainThread,用户启动的多个线程的名字依次为 Thread-1, Thread-2, ....



# 定义一个普通的 action 方法,该方法准备作为线程执行体
def action(max_):
    for i in range(max_):
        # 调用 threading 模块的 current_thread() 函数获取当前线程
        # 调用线程对象的 getName() 方法获取当前线程的名字
        print(threading.current_thread().getName() + " " + str(i))


# 主程序(主线程的线程执行体)
for i in range(100):
    # 调用 threading 模块的 current_thread() 函数获取当前线程
    print(threading.current_thread().getName() + " " + str(i))

    if i == 20:
        # 创建并启动第一个线程
        t1 = threading.Thread(target=action, args=(100,))
        t1.start()
        # 创建并启动第二个线程
        t2 = threading.Thread(target=action, args=(100,))
        t2.start()
print('主线程执行完成')
# 在进行多线程编程时,不要忘记 Python 程序运行时默认的主线程,主程序部分(没有放在任何函数中的代码)就是主线程的线程执行体
# 多线程就是让多个函数能并发执行,让普通用户感觉到多个函数几乎同时在执行

# 这段代码稍微会好理解一点
import threading


def threading_main(thread_port):
    for i in range(50):
        print(f'第{thread_port}个线程启动了, 循环到第{i}次')


threading.Thread(target=threading_main, args=(1,)).start()
threading.Thread(target=threading_main, args=(2,)).start()
threading.Thread(target=threading_main, args=(3,)).start()
threading.Thread(target=threading_main, args=(4,)).start()


print('主线程执行完成')

继承 Thread 类创建线程类

步骤如下:
1. 定义 Thread 类的子类,并重写该类的 run() 方法。run() 方法的方法体就代表了线程需要完成的任务,因此把 run() 方法称为线程执行体
2. 创建 Thread 子类的实例,即创建线程对象
3. 调用线程对象的 start() 方法来启动线程
import threading


# 通过继承 threading.Thread 类来创建实例
class FkThread(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self.i = 0

    # 重写 run() 方法作为线程执行体
    def run(self):
        while self.i < 100:
            # 调用 threading 模块的 current_thread() 函数获取当前线程
            # 调用线程对象的 getName() 方法获取当前线程的名字
            print(threading.current_thread().getName() + " " + str(self.i))
            self.i += 1


# 下面是主程序(也就是主线程的线程执行体)
for i in range(100):
    # 调用 threading 模块的 current_thread() 函数获取当前线程
    print(threading.current_thread().getName() + " " + str(i))
    if i == 20:
        # 创建并启动第一个线程
        ft1 = FkThread()
        ft1.start()
        # 创建并启动第二个线程
        ft2 = FkThread()
        ft2.start()
print('主线程执行完成')

推荐使用第一种方式来创建线程(调用 Thread 类的构造器创建线程)
这种方式不仅编程简单,而且线程直接包装 target 函数,具有更清晰的逻辑结构

线程的生命周期

新建和就绪状态

当程序创建了一个 Thread 对象 或 Thread 子类的对象之后,该线程就处于新建状态
当线程调用 strat() 方法之后,该线程就处于就绪状态

启动线程使用 strat() 方法,而不是 run() 方法!
永远不要调用线程的 run() 方法!
run() 方法是一个普通方法,而不是线程执行体

# 这段代码稍微会好理解一点
import threading


def threading_main(thread_port):
    for i in range(50):
        print(f'第{thread_port}个线程启动了, 循环到第{i}次')


# 直接调用线程对象的 run() 方法
# 系统会把线程对象当成普通对象, 把 run() 方法当成普通方法
# 下面两行代码并不会启动两个线程,而是依次执行两个 run() 方法
threading.Thread(target=threading_main, args=(1,)).run()
threading.Thread(target=threading_main, args=(2,)).run()

print('主线程执行完成')

只能对处于新建状态的线程调用 start() 方法
如果程序对同一个线程重复调用 start() 方法, 将引发 RuntimeError 异常

运行和阻塞

当发生如下情况,线程将会进入阻塞状态
  - 调用 sleep() 方法的线程经过了指定的时间
  - 线程调用的阻塞式 I\O 方法已经返回
  - 线程成功的获得了试图获取的锁对象
  - 线程正在等待某个通知时,其他线程发出了一个通知

线程死亡

线程会以如下三种方式结束,结束后就处于死亡状态
  - run() 方法或代表线程执行体的 target 函数执行完成,线程正常结束
  - 线程抛出一个未捕获的 Exception 或 Error
当主线程结束时,其他线程不受任何影响,并不会随之结束,一旦子线程启动起来后,它就拥有和主线程相同的地位,它不会受主线程的影响

为了测试某个线程是否已经死亡,可以调用线程对象的 is_alive() 方法
当线程处于就绪,运行,阻塞三种状态时,该方法将返回 True
当线程处于新建,死亡两种状态时,该方法将返回 False
不要试图对一个已经死亡的线程调用 start() 方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程运行
# 这段代码稍微会好理解一点
import threading


def threading_main(thread_port):
    for i in range(50):
        print(f'第{thread_port}个线程启动了, 循环到第{i}次')


# 直接调用线程对象的 run() 方法
# 系统会把线程对象当成普通对象, 把 run() 方法当成普通方法
# 下面两行代码并不会启动两个线程,而是依次执行两个 run() 方法
t1 = threading.Thread(target=threading_main, args=(1,))
t1.start()
print(t1.is_alive())
t2 = threading.Thread(target=threading_main, args=(2,))
t2.start()
print(t2.is_alive())
print('主线程执行完成')
# 试图再次启动线程
t1.start()
t2.start()

控制线程

join 线程

Thread() 提供了让一个线程等待另一个线程完成的方法 ---- join() 方法
当在某个程序执行流中调用其他线程的 join() 方法时,调用线程将被阻塞,直到被 join() 方法假如的 join 线程执行完成
# 这段代码稍微会好理解一点
import threading


def threading_main(thread_port):
    for i in range(10):
        print(f'第{thread_port}个线程启动了, 循环到第{i}次')


# 直接调用线程对象的 run() 方法
# 系统会把线程对象当成普通对象, 把 run() 方法当成普通方法
# 下面两行代码并不会启动两个线程,而是依次执行两个 run() 方法
t1 = threading.Thread(target=threading_main, args=(1,))
t1.start()
t1.join()  # 调用 join() 方法,必须等这个线程执行完毕后才会继续向下执行


t2 = threading.Thread(target=threading_main, args=(2,))
t2.start()
t2.join()  # 调用 join() 方法,必须等这个线程执行完毕后才会继续向下执行


print('主线程执行完成')


join(timeout=None) 方法可以指定一个 timeout 参数,该参数指定等待被 join 的线程的时间最长为 timeout 秒
如果在 timeout 秒内被 join 的线程还没有执行结束,则不再等待

后台线程

后台线程有一个特征:如果所有的前台线程都死亡了,那么后台线程会自动死亡

调用 Thread 对象的 daemon 属性可以将指定线程设置成后台线程
当所有的前台线程都死亡后,后台线程随之死亡
当在整个虚拟机中只剩下后台线程时,程序就没有继续运行的必要了,所以程序也就随之退出了
# 这段代码稍微会好理解一点
import threading


def threading_main(thread_port):
    for i in range(10):
        print(f'第{thread_port}个线程启动了, 循环到第{i}次')


t1 = threading.Thread(target=threading_main, args=(1,))
# 将此线程设置成后台线程
# 也可以在创建 Thread 对象时通过 daemon 参数将其设置为后台线程
t1.daemon = True
# 启动后台线程
t1.start()
for i in range(10):
    print(threading.current_thread().name + " " + str(i))
# ---- 程序执行到此处,前台线程 (主线程) 结束 ----
# 后台线程也应该随之结束

创建后台线程有两种方式:
  - 主动将线程的 daemon 属性设置为 True
  - 后台线程启动的线程默认是后台线程
当前台线程死亡后, Python 解释器会通知后台线程死亡
但是从它接收指令到做出响应需要一定时间
如果要将某个线程设置为后台线程,则必须在该线程启动之前进行设置
将 daemon 属性设置为 True,必须在 start() 方法之前进行,否则会引发 RuntimeError 异常

线程睡眠:sleep

如果需要让当前正在执行的线程暂停一段时间,并静茹阻塞状态,可以通过 time 模块的 sleep(secs) 函数来实现
import time


for i in range(10):
    print(f'当前时间: {time.ctime()}')
    # 调用 sleep() 函数让当前线程暂停 1s
    time.sleep(1)

线程同步

线程安全问题

举例:银行取钱。
从银行取钱的基本流程可以分为几个步骤:
  - 用户输入账户,密码,系统判断用户的账户,密码是否匹配
  - 用户输入取款金额
  - 系统判断账户余额是否大于取款金额
  - 如果余额大于取款金额,则取款成功;如果余额小于取款金额,则取款失败;
使用两个线程来模拟两个人使用同一账户并发取钱操作。(忽略检查账户密码操作,只模拟后面三个操作)
import threading
import time


class Account:  # 账户类
    def __init__(self, account_no, balance):
        # 封装账户编号和账户余额两个成员变量
        self.account_no = account_no
        self.balance = balance


# 定义模拟取钱函数,该函数根据执行账户,取钱数量进行取钱操作
def draw(account: Account, draw_amount):
    # 账户余额大于取钱数目
    if account.balance >= draw_amount:
        # 吐出钞票
        print(threading.current_thread().name + " 取钱成功! 吐出钞票: " + str(draw_amount))
        time.sleep(0.001)
        # 修改余额
        account.balance -= draw_amount
        print('余额为: ' + str(account.balance))
    else:
        print(threading.current_thread().name + " 取钱失败, 余额不足")


# 创建账户
acct = Account("1234567", 1000)
# 使用两个线程模拟从同一账户取钱
threading.Thread(name='甲', target=draw, args=(acct, 800)).start()
threading.Thread(name='乙', target=draw, args=(acct, 800)).start()

账户余额只有 1000 元时取出了 1600 元,且账户出现了负值。这不是银行所期望的结果

同步锁

Python 的 threading 模块映入了锁(Lock)
threading 提供了 Lock 和 Rlock 两个类,它们都提供了如下两个方法来加锁和释放锁
  - acquire(blocking=True, timeout=1): 请求对 Lock 或 Rlock 加锁,其中 timeout 参数指定加锁多少秒
  - release(): 释放锁

Lock 和 Rlock 区别如下:
  - threading.Lock: 它是一个基本的锁对象,每次只能锁定一次,其余的锁请求,需等待锁释放后才能获取
  - threading.Rlock: 它代表可重入锁(Reentrant Lock)。
                     对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以多次释放。
                     如果使用 Rlock,那么 acquire() 和 release() 方法必须成对出现。
                     如果调用了 n 次 acquire() 加锁,则必须调用 n 次 release() 才能释放锁
RLock 锁具有可重入性。同一个线程可以对已被加锁的 RLock 锁再次加锁,线程在每次调用 acquire() 加锁后,都必须显式调用 release() 方法来释放锁

Lock 时控制多个线程对共享资源进行访问的工具,锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程在开始访问共享资源之前应先请求获得 Lock 对象,当对共享资源访问完成后,程序释放对 Lock 对象的锁定

线程安全控制中,比较常见的是 RLock

class X:
    # 定义需要保证线程安全的方法
    def m(self):
        # 加锁
        self.lock.acquire()
        try:
            # 需要保证线程安全的代码
            # ...方法体
        # 使用 finally 块来保证释放锁
        finally:
            # 修改完成,释放锁
            self.lock.release()
 
 # 使用 Rlock 对象来控制线程安全,当加锁和释放锁出现在不同的作用范围内时,通常建议使用 finally 块确保在必要时释放锁
 

···
使用 Lock 对象可以非常方便地实现线程安全的类,线程安全的类具有如下特征:

  • 该类的对象可以被多个线程安全地访问
  • 每个线程在调用该对象的任意方法之后,都将得到正确的结果
  • 每个线程在调用该对象的任意方法之后,该对象都依然保持合理的状态

···

将银行取钱代码改写成线程安全的

import random
import threading
import time


class Account:  # 账户类
    def __init__(self, account_no, balance):
        # 封装账户编号和账户余额两个成员变量
        self.account_no = account_no
        self._balance = balance
        self.RLock = threading.RLock()

    # 账户余额不允许随意修改,职位 self._balance 提供 getter 方法
    def getBalance(self):
        return self._balance

    # 定义模拟取钱函数,该函数根据执行账户,取钱数量进行取钱操作
    def draw(self, draw_amount):
        # 加锁
        self.RLock.acquire()
        # 账户余额大于取钱数目
        try:
            if self._balance >= draw_amount:
                # 吐出钞票
                print(threading.current_thread().name + " 取钱成功! 吐出钞票: " + str(draw_amount))
                time.sleep(0.001)
                # 修改余额
                self._balance -= draw_amount
                print('余额为: ' + str(self._balance))
            else:
                print(threading.current_thread().name + " 取钱失败, 余额不足")
        finally:
            # 修改完成,释放锁
            self.RLock.release()


# 创建账户
acct = Account("1234567", 1000)
# 循环开启十次三个线程取钱
for i in range(10):
    threading.Thread(name=f'甲{i + 1}', target=acct.draw, args=(random.randint(50, 100),)).start()
    threading.Thread(name=f'甲{i + 2}', target=acct.draw, args=(random.randint(50, 100),)).start()
    threading.Thread(name=f'甲{i + 3}', target=acct.draw, args=(random.randint(50, 100),)).start()
可变类的线程安全是以降低程序的运行效率作为代价的:
  - 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(共享资源)的方法进行同步
  - 如果可变类有两种运行环境:单线程和多线程环境,则应该为该可变类提供两个版本
      - 线程不安全版本和线程安全版本
        - 单线程环境中使用线程不安全版本以保证性能
        - 多线程环境中使用线程安全版本

死锁

当两个线程相互等待对方释放同步监视器时就会发生死锁
Python 解释器没有监测,也没有采取措施来处理死锁的情况,所以在进行多线程编程时应该采取措施避免出现死锁。

死锁是很容易发生的,尤其是在系统中出现多个同步监视器的情况下
import threading
import time


class A:
    def __init__(self):
        self.lock = threading.RLock()

    def foo(self, b):
        try:
            self.lock.acquire()
            print(f'当前线程名: {threading.current_thread().name} 进入了 A 实例的 foo() 方法')
            time.sleep(0.2)
            print(f'当前线程名: {threading.current_thread().name} 企图调用 B 实例的 last() 方法')
            b.last()
        finally:
            self.lock.release()

    def last(self):
        try:
            self.lock.acquire()
            print('进入了 A 类的 last() 方法内部')
        finally:
            self.lock.release()


class B:
    def __init__(self):
        self.lock = threading.RLock()

    def bar(self, a):
        try:
            self.lock.acquire()
            print(f"当前线程名: {threading.current_thread().name} 进入了 B 实例的 bar() 方法")
            time.sleep(0.2)
            print(f"当前线程名: {threading.current_thread().name} 企图调用 A 实例的 last() 方法")
            a.last()
        finally:
            self.lock.release()

    def last(self):
        try:
            self.lock.acquire()
            print("进入了 B 类的 last() 方法内部")
        finally:
            self.lock.release()


a = A()
b = B()


def init():
    threading.current_thread().name = "主线程"
    # 调用 a 对象的 foo() 方法
    a.foo(b)
    print("进入了主线程之后")


def action():
    threading.current_thread().name = "副线程"
    # 调用 b 对象的 bar() 方法
    b.bar(a)
    print("进入了副线程之后")


# 以 action 为 target 启动新线程
threading.Thread(target=action).start()
# 调用 init() 函数
init()

运行上面代码,可以看出程序无法向下执行,也不会抛出任何异常,就一直“僵持”着(这就是死锁)
几种常见方式解决死锁问题:
  - 避免多次锁定
  - 具有相同的加锁顺序
  - 使用定时锁: acquire() 方法加锁时可指定 timeout 参数,该参数指定超过 timeout秒后会自动释放对 Lock 的锁定,这样就可以解开死锁了
  - 死锁检测:依靠算法机制来实现死锁预防机制,主要针对那些不可能实现按序加锁,也布恩那个使用定时所的场景

线程通信

使用 Condition 实现线程通信

Condition 对象总是需要有对应的 Lock 对象。
从 Condition 的构造器 __init__(self, lock=None) 可以看出,程序在创建 Condition 时可通过 lock 参数传入要绑定的 Lock 对象
如果不指定 lock 参数,在创建 Condition 时它会自动创建一个与之绑定的 Lock 对象
Condition 类提供了了如下几个方法:
  - acquire([timeout])/release():调用 Condition 关联的 Lock 的 acquire() 或 release() 方法
  - wait([timeout]): 导致当前线程进入 Condition 的等待池等待并释放锁
                     直到其他线程调用该 Condition 的 notify() 或 notify_all() 方法来唤醒其他线程。
                     在调用该 wait() 方法时可传入一个 timeout 参数,指定该线程最多等待多少秒
 - notify(): 唤醒在该 Condition 等待池中的单个线程并通知它,收到通知的线程将自动调用 acquire() 方法尝试加锁。
             如果所有的线程都在该 Condition 等待池中等待,则会选择唤醒其中一个线程,选择是任意性的
 - notify_all(): 唤醒在 Condition 等待池中等待的所有线程并通知它们
                     
import threading


class Account:
    # 定义构造器
    def __init__(self, account_no, balance):
        # 封装账户编号和账户余额两个成员变量
        self.account_no = account_no
        self._balance = balance
        self.cond = threading.Condition()
        # 定义代表是否已经存钱的旗标
        self._flag = False

    # 因为账户余额不允许随便修改,所以只为 self._balance 提供 getter 方法
    def getBanlance(self):
        return self._balance

    # 提供一个线程安全的 draw() 方法来完成取钱操作
    def draw(self, draw_amount):
        # 加锁,相当于调用 Condition 绑定的 Lock 的 acquire()
        self.cond.acquire()
        try:
            # 如果 self._flag 为 False, 表明账户中还没有人存钱进去,取钱方法被阻塞
            if not self._flag:
                self.cond.wait()
            else:
                # 执行取钱操作
                print(threading.current_thread().name + " 取钱 " + str(draw_amount))
                self._balance -= draw_amount
                print("账户余额为: " + str(self._balance))
                self._flag = False
                # 唤醒其他线程
                self.cond.notify_all()
        # 使用 finally 来释放锁
        finally:
            self.cond.release()

    def deposit(self, deposit_amount):
        # 加锁,相当于调用 Condition 绑定的 Lock 的 acquire()
        self.cond.acquire()
        try:
            # 如果 self._flag 为 True, 表明账户中已有人存钱进去,存款方法被阻塞
            if self._flag:
                self.cond.wait()
            else:
                # 执行存款操作
                print(threading.current_thread().name + " 存款:" + str(deposit_amount))
                self._balance += deposit_amount
                print("账户余额为: ", self._balance)
                # 将表明账户中是哦福已有存款的旗标设为 True
                self._flag = True
                # 唤醒其他线程
                self.cond.notify_all()
        finally:
            self.cond.release()


# 定义一个函数, 模拟 max 次执行取钱操作
def draw_many(account, draw_amount, max):
    for i in range(max):
        account.draw(draw_amount)


# 定义一个函数,模拟重复 max 次 存款操作
def deposit_many(account, deposit_amount, max):
    for i in range(max):
        account.deposit(deposit_amount)


# 创建一个账户
acct = Account("1234567", 0)

# 创建并启动一个取钱线程
threading.Thread(name="取钱者", target=draw_many, args=(acct, 800, 100)).start()

# 创建并启动一个存款线程
threading.Thread(name='存款者甲', target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(name='存款者乙', target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(name='存款者丙', target=deposit_many, args=(acct, 800, 100)).start()
# 3个存款者线程共有 300 次尝试存钱的操作,但 1 个取钱者线程只有 100 次尝试取钱的操作,所以程序最后被阻塞!
# 存款者线程只是在等待其他线程来取钱而已,并不是等待其他线程释放同步监视器,不要把死锁和程序阻塞等同起来

使用队列 (Queue) 控制线程通信

queue 模块下主要提供了三个类,分别代表三种队列(主要区别在于进队列,出队列的不同)

描述
queue.Queue(maxsize=0)FIFO(先进先出)的常规队列,maxsize 可以限制队列的大小。如果队列的大小达到队列的上限,就会加锁,再次加入元素时就会被阻塞,直到队列中的元素被消费。如果将 maxsize 设置为 0 或负数,则该队列的大小就是无限制的
queue.LifoQueue(maxsize=0)代表LIFO(后进先出)的队列,与 Queue 的区别就是出队列的顺序不同
PriorityQueue(maxsize=0)代表优先级队列。优先级最小的元素先出队列

这三个队列的属性和方法基本相同,它们都提供了如下属性和方法

方法名描述
Queue.qsize()返回队列的实际大小,也就是该队列中包含几个元素
Queue.empty()判断队列是否为空
Queue.full判断队列是否已满
Queue.put(item, block=True, timeout=None)向队列中放入元素,如果队列已满,且 bolck 参数为 True(阻塞),当前线程被阻塞,直到该队列的元素被消费;如果队列已满,且 block 参数为 False(不阻塞)则直接引发 queue.FULL 异常
Queue.put_nowait(item)向队列中放入元素,不阻塞。相当于在上一个方法中将 block 参数设置为 False
Queue.get(item, block=True, timeout=None)从队列中取出元素(消费元素)。如果队列已满,且 block 参数为 True(阻塞),当前线程被阻塞,timeout 指定阻塞时间,如果将 timeout 设置为 None,则代表一直阻塞,直到有元素被放入队列中;如果队列已空,且 block 参数为 False(不阻塞),则直接引发 queue.EMPTY 异常
Queue.get_nowait(item)从队列中取出元素,不阻塞。相当于在上一个方法中将 block 参数设置为 False

以普通的 Queue 为例介绍阻塞队列的功能和方法

import queue

# 定义一个长度为 2 的阻塞队列
bq = queue.Queue(2)
bq.put("Python")
bq.put("Python")
print('1111111111')
bq.put("Python")  # 阻塞线程
print('2222222222')

# 创建一个大小为 2 的 Queue(队列)
# 程序先向该队列中放入两个元素(此时队列还没有满,两个元素都可以被放入)
# 尝试放入第三个元素,如果 put() 方法尝试放入元素将会阻塞线程 与此类似的是 Queue 已空的情况下,使用 get() 方法尝试取出元素将会阻塞线程

使用 Queue 来实现线程通信

import threading
import time
import queue


def product(bq):
    str_tuple = ("Python", "Kotlin", "Swift")
    for i in range(99999):
        print(threading.current_thread().name, "生产者线程准备产生元组元素!")
        time.sleep(0.2)
        # 尝试放入元素,如果队列已满,则线程被阻塞
        bq.put(str_tuple[i % 3])
        print(threading.current_thread().name, "生产者生产元组元素完成!")


def consume(bq):
    while True:
        print(threading.current_thread().name, "消费者准备消费元组元素!")
        time.sleep(0.3)
        # 尝试取出元素,如果队列已空,则线程被阻塞
        t = bq.get()
        print(threading.current_thread().name, f"消费者消费 {t} 元素完成!")


# 创建一个容量为 1 的 Queue
bq = queue.Queue(maxsize=1)
# 启动三哥生产者线程
threading.Thread(target=product, args=(bq,)).start()
threading.Thread(target=product, args=(bq,)).start()
threading.Thread(target=product, args=(bq,)).start()
# 启动一个消费者线程
threading.Thread(target=consume, args=(bq,)).start()

使用 Event 控制线程通信

Event 是一种非常简单的线程通信机制:
	- 一个线程发出一个 Event
	- 另一个线程可通过 Event 被触发
Event 本身管理一个内部旗标
  - 程序可以通过 Event 的 set() 方法将该旗标设置为 True
  - 也可以调用 clear() 方法将旗标设置为 False
  - 可以调用 wait() 方法来阻塞当前线程,直到 Event 的内部旗标被设置为 True

Event 提供了如下方法

方法说明
is_set()该方法返回 Event 的内部旗标是否为 True
set()该方法将会把 Event 的内部旗标设置为 True,并唤醒所有处于等待状态的线程
clear()该方法将 Event 的内部旗标设置为 False,通常接下来会调用 wait() 方法来阻塞当前线程
wait(timeout=None)该方法会阻塞当前线程

Event 最简单的用法

import threading
import time

event = threading.Event()


def cal(name):
    # 等待事件,进入等待阻塞状态
    print(f"{threading.current_thread().name} 启动")
    print(f"{name} 准备开始计算状态")
    event.wait()
    # 收到事件后进入运行状态
    print(f"{threading.current_thread().name} 收到通知了")
    print(f"{name} 正式开始计算")


# 创建并启动两个线程,它们都会在 event.wait() 处等待
threading.Thread(target=cal, args=('甲',)).start()
threading.Thread(target=cal, args=('乙',)).start()
time.sleep(2)
print('------------')
# 发出事件
event.set()

线程池

线程池可以很好地提供性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池

线程池在系统启动时即创建大量空闲的线程
程序只要将一个函数提交给线程池,线程池就会启动一个空闲的线程来执行它
当该函数执行结束后,该线程并不会死亡,而是再次返回到线程中变成空闲状态,等待执行下一个函数

线程池可以有效的控制系统中并发线程的数量,当系统中包含有大量的并发线程时,会导致系统性能急剧下降
线程池的最大线程数参数可以控制系统中并发线程的数量不超过此数

使用线程池

线程池的基类是 concurrent.futures 模块中的 Executor
Executor 提供两个子类(ThreadPoolExecutor 和 ProcessPoolExecutor)
ThreadPoolExecutor 用于创建线程池
ProcessExecutor 用于创建进程池

如果使用线程池/进程池来管理并发编程,那么只要将相应的 task 函数提交给线程池/进程池,剩下的事情就由线程池/进程池搞定

Executor 提供了如下常用方法

方法描述
submit(fn, *args, **kwargs)将 fn 函数提交给线程池,*args 代表传给 fn 函数的参数,*kwargs 代表以关键字参数的形式为 fn 函数传入参数
map(func, *iterables, timeout=None, chunksize=1)该函数类似于全局函数 map(func, *iterables), 只是该函数将会启动多个线程,以异步方式立即对 iterables 执行 map 处理
shutdown(wait=True)关闭线程池

程序将 task 函数提交 (submit) 给线程池后,submit 方法会返回一个 Future 对象
Future 类主要用户获取线程任务函数的返回值
Future 提供了如下方法

方法描述
cancel取消该 Future 代表的线程任务。如果该任务正在执行,不可取消,则该方法返回 False;否则,程序会取消该任务,并返回 True
cancelled()返回 Future 代表的线程任务是否被成功取消
running()如果该 Future 代表的线程任务正在执行,不可被取消,该方法返回 True
done()如果该 Future 代表的线程任务被成功取消或执行完成,则该方法返回 True
result(timeout=None)获取该 Future 代表的线程任务最后返回的结果。如果 Futrue 代表的线程任务还未完成。该方法会阻塞当前线程,其中 timeout 参数指定最多阻塞多少秒
exception(timeout=None)获取该 Future 代表的线程任务所引发的异常。如果该任务成功完成,没有异常,则该方法返回 None
add_done_callback(fn)为该 Future 代表的线程任务注册一个 “回调函数”,当该任务成功完成时,程序会自动触发该 fn 函数

在用完一个线程后,应该调用该线程池的 shutdown() 方法,该方法将启动线程的关闭序列
调用 shutdown() 方法 后的线程池不再接收新任务,但会将以前所有已提交任务执行完成
当线程池中的所有任都执行完成后,该线程池中的所有线程都会死亡

使用线程池来执行线程任务的步骤如下:
  1. 调用 ThreadPoolExecutor 类的构造器创建一个线程池
  2. 定义一个普通函数作为线程任务
  3. 调用 ThreadPoolExecutor 对象的 submit() 方法来提交线程任务
  4. 当不想提交任何任务时,调用 ThreadPoolExecutor 对象的 shutdown() 方法来关闭线程池
from concurrent.futures import ThreadPoolExecutor
import threading
import time


# 定义一个准备作为线程任务的函数
def action(max_):
    my_sum = 0
    for i in range(max_):
        print(threading.current_thread().name + ' ' + str(i))
        my_sum += i
    return my_sum

# 创建一个包含两个线程的线程池
pool = ThreadPoolExecutor(max_workers=2)
# 向线程池中提交一个任务, 50 会作为 action() 函数的参数
future1 = pool.submit(action, 50)
# 向线程池中再提交一个任务,100 会作为 action() 函数的参数
future2 = pool.submit(action, 100)
# 判断 future1 代表的任务是否结束
print(future1.done())
time.sleep(3)
# 判断 future2 代表的任务是否结束
print(future2.done())
time.sleep(3)

# 查看 future1 代表的任务返回的结果
print(future1.result())

# 查看 future2 代表的任务返回的结果
print(future2.result())

# 关闭线程池
pool.shutdown()

获取执行结果

调用 Future 的 result() 方法可以获取线程任务的返回值,该方法会阻塞当前主线程,只有等到线程任务完成后,result() 方法的阻塞才会被解除.
如果不希望直接调用 result() 方法阻塞线程,可通过 Future 的 add)done_callback() 方法来添加回调函数,回调函数形如 fn(future)。 线程任务完成后,自动触发该回调函数,并将对应的 Future 对象作为参数传给回调函数

使用 add_done_callback() 方法来获取线程任务的返回值

from concurrent.futures import ThreadPoolExecutor
import threading
import time


# 定义一个准备作为线程任务的函数
def action(max_):
    my_sum = 0
    for i in range(max_):
        print(threading.current_thread().name + " " + str(i))
        my_sum += 1
    return my_sum


# 定义一个函数 用于获取 Future 的返回值
def get_result(future):
    print('future.result: ', future.result())


# 创建一个包含两个线程的线程池
with ThreadPoolExecutor(max_workers=2) as pool:
    # 向线程池中提交一个任务, 50 会作为 action() 函数的参数
    future1 = pool.submit(action, 50)
    # 向线程池中再提交一个任务,100会作为 action() 函数的参数
    future2 = pool.submit(action, 100)
    # 为 future1 添加线程完成的回调函数
    future1.add_done_callback(get_result)
    # 为 future2 添加线程完成的回调函数
    future2.add_done_callback(get_result)
    print('--------')  # 主线程的打印,在打印 ------ 之后线程还在继续运行,说明add_done_callabck 并不会阻塞主线程

Exectuor 还提供了一个 map(func, *iterable, timeout=None, chunksize=1) 方法
该方法的功能类似于全局函数 map(). 区别在于线程池的 map() 方法会为 iterables 的每一个元素启动一个线程,以并发方式来执行 func 函数
相当于启动 len(iterables) 个线程,并收集线程任务的返回值
```python
from concurrent.futures import ThreadPoolExecutor
import threading
import time


# 定义一个准备作为线程的任务函数
def action(max_):
    my_sum = 0
    for i in range(max_):
        print(threading.current_thread().name + ' ' + str(i))
        my_sum += i
    return my_sum


# 创建一个包含 4 个线程的线程池
with ThreadPoolExecutor(max_workers=4) as pool:
    # 使用线程执行 map 计算
    # 后面的元组由 3 个元素,因此程序启动 3 个线程来执行 action 函数
    results = pool.map(action, (50, 100, 150))
    print('=========')
    # map 方法的返回值将会收集每个线程任务返回的结果
    # 通过 results 可以看到 3 个线程任务的返回结果
    for r in results:
        print('for r in results: ', r)

线程相关类

Python 在 threading 模块下提供了一个 local() 函数,该函数可以返回一个线程局部变量,通过使用线程局部函数变量可以很简捷地隔离多线程访问地竞争资源,从而简化多线程并发访问地编程处理
import threading
from concurrent.futures import ThreadPoolExecutor

# 定义局部变量
mydata = threading.local()


# 定义准备作为线程执行体使用的函数
def action(max_):
    for i in range(max_):
        try:
            mydata.x += i
        except:
            mydata.x = i
        # 访问 mydata 的 x 的值
        print(threading.current_thread().name, mydata.x)


# 使用线程池启动两个子线程
with ThreadPoolExecutor(max_workers=2) as pool:
    pool.submit(action, 10)
    pool.submit(action, 10)

定时器

Thread 类有一个 Timer 子类,该子类可用于控制指定函数在特定时间内执行一次
from threading import Timer


def hello():
    print("hello World")


# 指定 10s 后执行 hello 函数
t = Timer(10.0, hello)
t.start()

如果程序想取消 Timer 的调度,可调用 Timer 对象的 cancel() 函数

from threading import Timer
import time

# 定义总共输出几次的计数器
count = 0


def print_time():
    print('当前时间: ', time.ctime())
    global t, count
    count += 1
    # 如果 count 小于 10,开始下一次调度
    if count < 10:
        t = Timer(1, print_time)
        t.start()


# 指定 1s 后执行 print_time 函数
t = Timer(1, print_time)
t.start()

任务调度

需要执行更复杂的任务调度,可使用 Python 提供的 sched 模块,该模块提供了 sched.scheduler 类,该类代表一个任务调度器

sched.scheduler(timefunc=time.monotonic, delayfunc=time.sleep) 构造器支持两个参数
timefunc: 该参数指定生成时间戳的时间函数,默认使用 time.sleep 函数来阻塞程序
delayfunc: 该参数指定阻塞程序的函数,默认使用 time.sleep 函数来阻塞程序
scheduler.enterabs(time, priority, action, argument=(), kwargs={});
  - 指定在 time 时间点执行 action 函数
  - argument 和 kwargs 用于向 action 函数传入参数,argument 使用位置参数形式,kwargs 使用关键字参数形式
  - 该方法返回一个 event,它可作为 cancel() 方法的参数用于取消该调度
  - priority 参数指定该任务的优先级,当在同一个时间点有多个任务需要执行时,优先级高(值越小代表优先级越高)的任务会优先执行

scheduler.enter(delay, priority, action, argument=(), kwargs={})
  - 与上一个方法基本相同
  - delay 参数用于指定多少秒之后执行 action 任务

scheduler.cancel(event)
  - 取消任务
  - 如果传入 event 参数不是当前调度队列中的 event,程序将会引发 ValueError 异常

scheduler.empty()
  - 判断当前该调度器的调度队列是否为空

scheduler.queue
  - 该只读属性返回该嗲赌气的调度队列

示范使用 sched.scheduler 来执行任务调度

import sched, time
import threading

# 定义线程调度器
s = sched.scheduler()


# 定义被调度的函数
def print_time(name="default"):
    print(f'{name} 的时间: {time.ctime()}')


print('主线程: ', time.ctime())
# 指定 10s 后执行 print_time 函数
s.enter(10, 1, print_time)
# 指定 5s 后执行 print_time 函数, 优先级为2
s.enter(5, 2, print_time, argument=('位置参数',))
# 指定 5s 后执行 print_time 函数, 优先级为1
s.enter(5, 1, print_time, kwargs={"name": "关键字参数"})
# 执行调度的任务
s.run()
print('主线程: ', time.ctime())

多进程

使用 multiprocessing.Process 创建新进程

multiprocessing 模块下提供了 Process 来创建新进程
  - 以指定函数作为 targer,创建 Process 对象即可创建新进程
  - 继承 Process 类,并重写它的 run() 方法来创建进程类,创建 Process 子类的实例作为进程

Process 类的方法和属性

方法和属性描述
run()重写该方法可实现进程的执行体
start()该方法用于启动进程
join([timeout])该方法类似于线程的 join() 方法,当前进程必须等待被 join 的进程执行完成才能向下执行
name该属性用于设置或访问进程的名字
is_alive()判断进程是否还活着
daemon该属性用于判断或设置进程的后台状态
pid返回进程的 ID
authkey返回进程的授权 key
terminate()中断该进程

以指定函数作为 target 创建新进程

import multiprocessing
import os


def action(max_):
    for i in range(max_):
        print(f'{os.getpid()}子进程(父进程{os.getppid()}):{i}')


if __name__ == '__main__':
    # 下面是主程序(也就是主进程)
    for i in range(100):
        print(f"{os.getpid()}主进程: {i}")
        if i == 20:
            # 创建并启动第一个进程
            mp1 = multiprocessing.Process(target=action, args=(100,))
            mp1.start()
            # 创建并启动第二个进程
            mp2 = multiprocessing.Process(target=action, args=(100,))
            mp2.start()
            mp2.join()
    print('主进程执行完成')

继承 Process 类创建子进程

1. 定义继承 Process 的子类,重写其 run() 方法准备作为进程的执行体
2. 创建 Process 子类的实例
3. 调用 Process 子类的实例的 start() 方法来启动进程
import multiprocessing
import os


class MyProcess(multiprocessing.Process):
    def __init__(self, max_):
        self.max = max_
        super().__init__()

    # 重写 run() 方法作为进程执行体
    def run(self):
        for i in range(self.max):
            print(f'{os.getpid()}子进程(父进程{os.getppid()}):{i}')


if __name__ == '__main__':
    # 下面是主程序(也就是主进程)
    for i in range(100):
        print(f"{os.getpid()}主进程: {i}")
        if i == 20:
            # 创建并启动第一个进程
            mp1 = MyProcess(100)
            mp1.start()
            # 创建并启动第二个进程
            mp2 = MyProcess(100)
            mp2.start()
            mp2.join()
    print('主进程执行完成')

使用进程池管理进程

如果程序需要启动多个进程,也可以使用进程池来管理进程
通过 multiprocessing 模块的 Pool() 函数创建进程池,进程池实际上是 multiprocessing.pool.Pool 类
方法或属性描述
apply(func[,kwds])将 func 函数提交给进程池处理。args 代表传给 func 的位置参数,kwds 代表传给 func 的关键字参数。该方法会被阻塞直到 func 函数执行完成
apply_async(func,[,args[,kwds[,callbace[, error_callback]]]])这是 apply() 方法的异步版本,该方法不会被阻塞。其中 callback 指定 func 函数完成后的额回调函数,erroe_callback 指定 func 函数出错后的回调函数
imap(func, iterable[, chunksize])这是 map() 方法的延迟版本
imap_unordered(func, iterable[, chunksize])功能类似于 imap() 方法,但该方法不能保证所生成的结果(包含多个元素)与原 iterable 中的元素顺序一致
starmap(func, iterable[, chunksize])功能类似于 map() 方法,但该方法要求 iterable 的元素也是 iterable 对象,程序会将每一个元素解包之后作为 func 函数的参数
close()关闭进程池,在调用该方法之后,该进程池不能再接收新任务,它会把当前进程池中的所有任务执行完成后再关闭自己
terminate()立即终止进程池
join()等待所有进程完成
import multiprocessing
import os
import time


def action(name='default'):
    print(f'{os.getpid()} 进程正在执行,参数为 {name}')


if __name__ == '__main__':
    # 创建包含 4 个进程的进程池
    pool = multiprocessing.Pool(processes=4)
    # 将 action 分 3 次提交给进程池
    pool.apply_async(action)
    pool.apply_async(action, args=('位置参数',))
    pool.apply_async(action, kwds={'name': '关键字参数'})
    pool.close()
    pool.join()

使用 with 自居管理进程池(避免程序主动关闭进程池)

import multiprocessing
import os
import time


def action(name):
    print(f'{os.getpid()} 进程正在执行,参数为 {name}')
    return name


if __name__ == '__main__':
    # # 创建包含 4 个进程的进程池
    # pool = multiprocessing.Pool(processes=4)
    # # 将 action 分 3 次提交给进程池
    # pool.apply_async(action)
    # pool.apply_async(action, args=('位置参数',))
    # pool.apply_async(action, kwds={'name': '关键字参数'})
    # pool.close()
    # pool.join()
    with multiprocessing.Pool(processes=4) as pool:
        # 使用进程执行 map 计算
        # 后面元组有 3 个元素,因此程序启动 3 个进程来执行 action 函数
        results = pool.map(action, ('1', '2', '3'))
        for r in results:
            print(r)

进程通信

Python 为进程通信提供了两种机制:
  - Queue: 一个进程向 Queue 中放入数据,另一个进程从 Queue 中读取数据
  - Pipe:Pipe 代表连接两个进程的管道。调用 Pipe() 函数时会产生两个连接端,分别交给通信的两个进程,接下来进程既可以从该连接端读取数据,也可向该连接端写入数据

使用 Queue 实现进程通信

import multiprocessing


def f(q):
    print(f'{multiprocessing.current_process().pid} 开始放入数据...')
    q.put('Python')


if __name__ == '__main__':
    # 创建进程通信的 Queue
    q = multiprocessing.Queue()
    # 创建子进程
    p = multiprocessing.Process(target=f, args=(q,))
    # 启动子进程
    p.start()
    print(f'{multiprocessing.current_process().pid} 进程开始取出数据...')
    # 取出数据
    print(q.get())  # Python
    p.join()

使用 pipe 实现进程通信

调用 multiprocessing.Pipe() 函数创建一个管道,该函数会返回两个 PipeConnection 对象
代表管道的两个连接端(一个管道有两个连接端,分别用于连接通信的两个进程)
import multiprocessing


def f(conn):
    print(f'{multiprocessing.current_process().pid} 进程开始发送数据...')
    conn.send('Python')


if __name__ == '__main__':
    # 创建 Pipe 该函数返回两个 PipeConnection 对象
    parent_conn, child_conn = multiprocessing.Pipe()
    # 创建子进程
    p = multiprocessing.Process(target=f, args=(child_conn,))
    # 启动子进程
    p.start()
    print(f'{multiprocessing.current_process().pid} 进程开始接收数据')
    # 通过 conn 读取数据
    print(parent_conn.recv())
    p.join()
  • 6
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值