疯狂Python讲义学习笔记(含习题)之并发编程(多线程)

单线程时,如果程序在执行某行代码时遇到阻塞,程序将会停滞在该处。单线程程序只有一个顺序执行流,而多线程程序则可以包含多个顺序执行流,这些顺序执行流之间互不干扰。

一、线程概述

一个操纵系统支持同时运行多个任务,一个任务就是一个程序,每个运行的程序就是一个进程,每个进程包含多个顺序执行流,每一个顺序执行流就是一个线程。

(一)线程和进程

每个运行中的任务对应一个进程(Process),进程是处于运行过程中的程序,并且具有一定的独立功能。进程是系统进行资源分配和调度的一个独立单位。

可以将一个程序当成一个提供服务的机构,比如餐厅,操作系统就是餐厅的CEO,负责对餐厅的资源进行分配和调度,每个餐厅都会有多个服务员为顾客服务(个别比较小的餐厅也可能只有一个服务员),他们为顾客提供的服务大致是相同的,这里的餐厅就是一个进程,而每个服务员就是一个线程,每个服务员都可以为不同的顾客提供服务,服务员为客户提供服务的过程就是一个顺序执行流,多个服务员之间相互不会影响,各自根据规范流程为各自的客户服务。

进程的特征:

● 独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己的私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。这就好比每一间餐厅都有自己的特色,有的餐厅提供中餐,有的餐厅提供西餐,如果两家餐厅之间没有经过协商达成共识,那么中餐厅不能给他的顾客提供西餐厅的服务。

● 动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,在程序中是没有这些概念的。还是用餐厅做比喻,如果餐厅没有服务员,那么这家餐厅只是具备提供点餐服务这个功能,但顾客并不能从餐厅得到服务。而有了服务员以后就可以实现点餐服务了,而每一个服务员的状态又不尽相同,并不是每时每刻都是所有的服务员都在工作的。

● 并发性:多个进程可以再单个处理器上并发执行,多个进程之间不会相互影响。也就是说,几个服务员可以同时服务不同的顾客,而不必等待一个服务员服务结束之后,另一个服务员才能为其他顾客提供服务。他们之间的工作不会相互影响。

※ 并发(Concurrency)和并行(Parallel)是不同的,并行指同一时刻有多条指令在多个处理器上同时执行,并发指同一个时刻只能有一条指令执行,但多个进程指令被快速轮换执行,是的宏观上具有多个进程同时执行的效果。

 

多进程策略有:共用式的多任务操作策略(Windows3.1和Mac OS 9)以及抢占式多任务操作策略(Windows NT、Windows 2000以及UNIX/Linux)

 

线程:(Thread)也被称为轻量级进程(Lightweight Process),线程是进程的执行单元。线程在程序中是独立的、并发的执行流。当进程被初始化之后,主线程就被创建了。

对于大多数程序而言,有一个主线程就够了。线程是进程的组成部分,一个进程可以有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆栈、自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享进程所拥有的全部资源。

线程可以完成一定的任务,可以与其他线程共享父进程中的共享变量及部分环境,相互之间协同来完成进程所要完成的任务。

线程的运行是抢占式的,当前运行的线程在任何时候都可能被挂起,以便另外一个线程可以运行。

一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发运行。

一个程序运行之后至少有一个进程,而一个进程至少要包含一个主线程。

※操作系统可以同时执行多个任务,每一个任务就是一个进程,进程可以同时执行多个任务,每一个任务就是一个线程。

 轮询调度资源管理内部约束
进程由操作系统负责管理有独立的堆栈、内存管理空间由于独立性,进程之间无约束,一个process die之后,不影响其他process
线程有程序解释器来负责管理共享同一进程的资源共享资源,互相约束,一个线程die之后,整个进程可能崩溃

(二)多线程的优势

1. 进程在执行过程中拥有独立的内存单元,多个线程共享这些内存,从而极大地提高了程序的运行效率。

2. 多个线程共享同一个进程的虚拟空间,利用这些共享的数据,线程之间很容易实现通信。

3. 创建进程需要分配独立的内存空间,并分配大量的相关资源,创建线程则简单得多,因此,使用多线程来实现并发比使用多进程来实现并发的性能要高得多。

 

二、线程的创建和启动

Python提供了_thread和threading两个模块来支持多线程,其中_thread提供低级别的、原始的线程支持,以及一个简单的锁,一般不建议使用_thread模块。

Python创建线程的两种方式:

1. 使用threading模块的Thread类的构造器创建线程。

2. 继承threading模块的Thread类创建线程类。

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

__init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, deamon=None)

● group:指定该线程所属的线程组。目前该参数还未实现,因此它只能设为None。

● target:指定该线程要调度的目标方法。

● args:指定一个元组,以位置参数的形式为target指定的函数传入参数。元组的第一个元素传个target函数的第一个参数,元组的第二个元素传个target函数的第二个参数……以此类推。

● kwargs:指定一个字典,以关键字参数的形式为target指定的函数传入参数。

● deamon:指定所构造的线程是否为后代线程。

通过Thread类的构造器创建并启动多线程的步骤如下:

① 调用Thread类的构造器创建线程对象。在创建线程对象时,target参数指定的函数将作为线程执行体。

②调用线程对象的start()方法启动该线程。

import threading


# 定义个普通的acton方法,该方法准备作为线程执行体
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('主线程执行完成!')

执行结果:

……
MainThread 93
MainThread 94
Thread-1 78
MainThread 95
Thread-2 47
Thread-2 48
Thread-1 79
Thread-2 49
……

从执行结果可以看出,程序共创建了三个线程:MainThread、Thread-1、Thread-2,这三个线程的执行没有先后顺序,他们以并发方式执行:Thread-1执行一段时间,然后可能Thread-2或MainThread获得CPU执行一段时间,接下来又haunted其他线程执行。

※ 实际上,多线程就是让多个函数能并发执行,让普通用户感觉到多个函数似乎同时在执行。

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

(二)继承Thread类创建线程

步骤:

① 定义Thread类的子类,并重写该类的run()方法。run()方法的方法体就代表了线程需要完成的任务,因此把run()方法称为线程执行体。

② 创建Thread子类的实例,即创建线程对象。

③ 调用线程对象的strar()方法来启动线程。

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('主线程执行完成!')

以上代码的执行效果,同第一个程序的执行效果一样。

 

三、线程的生命周期

线程并非一启动就进入执行状态,也不是一直处于执行状态,在线程的生命周期中,要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5中状态。

(一) 新建和就绪状态

当程序创建了一个Thread对象或Thread子类对象之后,该线程就处于新建状态,当线程对象调用start()方法之后,该线程处于就绪状态,Python解释器会为其创建方法调用栈和程序计数器,处于这种状态中的线程并没有开始运行,只是表示该线程可以运行了,至于该线程何时开始运行,取决于Python解释器中线程调度器的调度。

启动线程使用strat()方法,而不是run()方法!永远不要调用线程对象的run()方法!调用start()方法来启动线程,系统会把该run()方法当成线程执行体处理,如果直接调用run()方法,则run()方法立即就会被执行,而且在该方法返回之前其他线程无法并发执行——也就是说,如果直接调用run()方法,则系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。

import threading


# 定义准备作为线程执行体的action函数
def action(max):
    for i in range(max):
        # 当直接调用run()方法是,Thread的name属性返回的是该对象的名字
        # 而不是当前线程的名字
        # 使用threading.current_thread().name总是获取当前线程的名字
        print(threading.current_thread().name + " " + str(i))


for i in range(100):
    # 调用Thread的current_thread()函数获取当前线程
    print(threading.current_thread().name + " " + str(i))
    if i == 20:
        # 直接调用线程对象的run()方法
        # 系统会把线程对象当成普通对象,把run()方法当成普通方法
        # 所以下面两行代码并不会启动两个线程,而是一次执行两个run()方法
        threading.Thread(target=action, args=(100,)).run()
        threading.Thread(target=action, args=(100,)).run()

 

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

(二)运行和阻塞状态

处于就绪状态的线程获得CPU后,就开始执行run()方法的线程执行体,则该线程出于运行状态。

如果只有一个CPU,那么任何时刻只有一个线程处于运行状态。如果有多个CPU,则会有多个线程并行执行,当线程数大于处理器数时,依然会出现多个线程在同一个CPU上轮换的情况。

线程并不是一直处于运行状态的(除非线程执行体足够短,瞬间就执行结束了),线程运行过程中需要被终端,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。

采用抢占式调度策略的系统会给每一个可执行的线程一个小时间段来处理任务,当时间段用完后,系统会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程的时候,系统会考虑线程的优先级。

所有的现代桌面和服务器操作系统都采用抢占式调度策略。

一些小型设备如手机可能采用协作式调度策略,在这样的系统中,只有当一个线程调用了sleep()或yield()方法后才会放弃所占用的资源。

进入阻塞状态的情况:

● 线程调用sleep()方法主动放弃所占用的处理器资源。

● 线程调用了一个阻塞式I/O方法,在该方法返回之前,该线程被阻塞。

● 线程试图获得一个锁对象,但该锁对象正在被其他线程所持有。

● 线程在等待某个通知(Notify)

正在执行的线程被阻塞之后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,而非运行状态,阻塞解除后,线程必须重新等待线程调度器再次调度它。

解除阻塞的情况:

● 调用sleep()方法的线程经过了指定的时间。

● 线程调用的阻塞式I/O方法已经返回。

● 线程成功地获得了试图获取的锁对象。

● 线程正在等待某个通知时,其他线程发出了一个通知。

 

 

(三)线程死亡

线程结束的二种方式:

● run()方法或代表线程执行体的target函数执行完成,线程正常结束。

● 线程抛出一个未捕获的Exception或Error。

当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动之后,就拥有了同主线程一样的地位,而不受主线程影响。

线程对象的is_alive()方法可以测试线程是否死亡,当线程处于就绪、运行、阻塞三种状态时,该方法将返回True;当线程处于新建、死亡两种状态时,该方法返回False。

对一个已经死亡的线程调用start()方法将引发RuntimeError异常。

(四)控制线程

(一)join线程

Thread提供了让一个线程等待另一个线程完成的方法——join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完成。

join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,并为每个小问题分配一个线程,当所有小问题都得到处理后,再调用主线程来进一步操作。

import threading


# 定义action函数准备作为线程执行体使用
def action(max):
    for i in range(max):
        print(threading.current_thread().name + " " + str(i))


# 启动子线程
threading.Thread(target=action, args=(100,), name="新线程").start()
for i in range(100):
    if i == 20:
        jt = threading.Thread(target=action, args=(100,), name="被Join的线程")
        jt.start()
        # 主线程调用了jt线程的join()方法
        # 主线程必须等jt执行结束后才会向下执行
        jt.join()
    print(threading.current_thread().name + " " + str(i))

执行结果:

………………
被Join的线程 96
被Join的线程 97
被Join的线程 98
被Join的线程 99
MainThread 20
MainThread 21
MainThread 22
MainThread 23
MainThread 24
………………

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

(二)后台线程

有一种线程,它在后台运行,任务是为其他线程提供服务,这种线程被称为“后台线程(Deamon Thread)”,也被称为“守护线程”或“精灵线程”。例如Python解释器的垃圾回收线程。

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

Thread对象的daemon属性可以将指定线程设置为后台线程。

import threading


# 定义后台线程的线程执行体与普通线程没有任何区别
def action(max):
    for i in range(max):
        print(threading.current_thread().name + " " + str(i))


t = threading.Thread(target=action, args=(100,), name="后台线程")
# 将此线程设置成后台线程
t.daemon = True
# 启动后台线程
t.start()
for i in range(100):
    print(threading.current_thread().name + " " + str(i))
#-------------程序执行到此处,前台线程(主线程)结束---------------
# 后台线程也应该随之结束

运行效果:

……………………
后台线程 57
后台线程 58
后台线程 59
后台线程 60
MainThread 94
MainThread 95
MainThread 96
MainThread 97
MainThread 98
MainThread 99


Process finished with exit code 0

(三)线程睡眠:sleep

如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用time模块的sleep(secs)函数来实现。该函数指定一个secs参数,用于指定线程阻塞多少秒。

当线程调用sleep()进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行机会,即使系统中没有其他可执行线程,出于sleep()中的线程也不会执行,因此sleep()函数通常用来暂停程序的运行。

import time

for i in range(10):
    print("当前时间:%s" % time.ctime())
    # 调用sleep()函数让当前线程暂停5s
    time.sleep(5)

执行效果:

当前时间:Thu Oct 31 23:48:35 2019
当前时间:Thu Oct 31 23:48:40 2019
当前时间:Thu Oct 31 23:48:46 2019
当前时间:Thu Oct 31 23:48:51 2019
当前时间:Thu Oct 31 23:48:56 2019
当前时间:Thu Oct 31 23:49:01 2019
当前时间:Thu Oct 31 23:49:06 2019
当前时间:Thu Oct 31 23:49:11 2019
当前时间:Thu Oct 31 23:49:16 2019
当前时间:Thu Oct 31 23:49:21 2019

Process finished with exit code 0

五、线程同步

(一)线程安全问题

一个典型的线程安全问题——银行取钱问题。

① 用户输入帐户、密码,系统判断用户的帐户、密码是否匹配

② 用户输入取款金额

③ 系统判断帐户余额是否大于取款金额

④ 如果余额大于取款金额,则取款成功,否则取款失败

具体模拟代码如下:

class Account:
    # 定义构造器
    def __init__(self, account_no, balance):
        # 封装帐户编号(银行卡号)和帐户余额两个变量
        self.account_no = account_no
        self.balance = balance
import threading
import time

import Account


# 定义一个函数来模拟取钱操作
def draw(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("\t余额为:" + str(account.balance))
    else:
        print(threading.current_thread().name + "取钱失败!余额不足!")


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

运行结果:

甲取钱成功!吐出钞票:800
已取钱成功!吐出钞票:800
	余额为:200	余额为:-600


Process finished with exit code 0

(二)同步锁(Lock)

关于银行取钱问题的错误原因是因为run()方法的方法体不具备线程安全性——程序中有两个并发线程在修改Account对象;为了解决这个问题,Python的threading模块引入了锁(Lock)。threading模块提供了Lock和Rlock两个类,它们都提供了如下两个方法来加锁和释放锁。

● acquire(blocking=True, timeout=-1):请求对Lock或Rlock加锁,其中timeout参数指定加锁多少秒。

● release():释放锁。

Lock和RLock区别:

● Lock:基本的锁对象,每次只能锁定一次,其余的锁请求,需等待锁释放后才能获取。

● RLock:代表可重入锁(Reentrant Lock)。对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以多次释放。如果使用RLock,则acquire()和release()方法必须成对出现。

RLock锁具有可重入性,同一线程可以对已被加锁的RLock锁再次加锁,RLock对象会维持一个计数器来追踪acquire()方法的嵌套调用,线程在每次调用acquire()加锁后,都必须显式调用release()方法来释放锁。所以,一段被锁保护的方法可以调用另一个被相同锁保护的方法。

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

RLock代码格式:

class X:
    # 定义需要保证线程安全的方法
    self.lock.acquire()
    try:
        # 需要保证线程安全的代码
        # ……方法体
    # 使用finally块来保证释放锁
    finally:
        # 修改完成,释放锁
        self.lock.release()

使用RLock对象来控制线程安全,当加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁。

线程安全类的特征:

● 该类的对象可以被多个线程安全地访问。

● 每个线程在调用该对象的任意方法之后,都将得到正确的结果。

● 每个线程在调用该对象的任意方法之后,该对象都依然保持合理的状态。

将Account类改为如下形式,它就是线程安全的。

import threading
import time


class Account:
    # 定义构造器
    def __init__(self, account_no, balance):
        # 封装帐户编号(银行卡号)和帐户余额两个变量
        self.account_no = account_no
        self._balance = balance
        self.lock = threading.RLock()

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

        # 提供一个线程安全的draw()方法来完成取钱操作
        def draw(self, draw_amount):
            # 加锁
            self.lock.acquire()
            try:
                # 帐户余额大于取钱数目
                if self._balance >= draw_amount:
                    # 吐出钞票
                    print(threading.current_thread().name + "取钱成功!吐出钞票:" +
                          str(draw_amount))
                    time.sleep(0.001)
                    # 修改余额
                    self._balance -= draw_amount
                    print("\t余额为:" + str(self._balance))
                else:
                    print(threading.current_thread().name + "取钱失败!余额不足!")
            finally:
                # 修改完成,释放锁
                self.lock.release()

以上代码中,在对象初始化的时候定义了一个RLock对象。在程序中实现draw()方法时,进入该方法开始执行后立即请求对RLock对象加锁,当执行完draw()犯法的取钱逻辑后,程序使用finally来确保释放锁。

程序中RLock对象作为同步锁,线程每次开始执行draw()方法修改self._blance时,都必须先对RLock对象加锁。当该线程完成对self._balance的修改,将要推出draw()方法时,则释放对RLock对象的锁定。完成“加锁→修改→释放锁”的安全访问逻辑。

并发线程在任意时刻只能有一个线程可以进入修改共享资源的代码区(也被称为临界区),从而保证了线程安全。

以下代码创建并启动了两个取钱线程:

import threading

import Account


# 定义一个函数来模拟取钱操作
def draw(account, draw_amount):
    # 直接调用account对象的draw()方法来执行取钱操作
    account.draw(draw_amount)


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

 

※领域驱动设计(Domain Driven Dsign,DDD),这种方式认为每个类都应该是完备的领域对象,例如Account代表用户账户,应该提供用户账户的相关方法;通过draw()方法来执行取钱操作(实际上还应该提供transfer()等方法来完成转账等操作),而不是直接将setBalance()方法暴露出来任人操作,这样才能更好地保证Account对象的完整性和一致性。

可变类的线程安全是一降低程序的运行效率为代价的,为了减少线程安全所带来的负面影响,程序可以采用如下策略。

● 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是同享资源)的方法进行同步。

● 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为可变类提供两种版本,即线程安全版本和线程不安全版本。

(三)死锁

当两个线程相互等待对方释放同步监视器时就会发生死锁。

Python解释器没有监测,也没有采取措施来处理死锁情况,所以在进行多线程编程时应该采取措施避免出现死锁。

一个死锁的例子:

import threading
import time


class A:

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

    def foo(self, b):
        try:
            self.lock.acquire()
            print("当前线程名:" + threading.current_thread().name +
                  " 进入了A实例的foo()方法")
            time.sleep(0.2)
            print("当前线程名:" + 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("当前线程名:" + threading.current_thread().name +
                  " 进入了B实例的bar()方法")
            time.sleep(0.2)
            print("当前线程名:" + 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)


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


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

代码输入如下信息后停住了,并且程序永远不会结束。

当前线程名:副线程 进入了B实例的bar()方法
当前线程名:主线程 进入了A实例的foo()方法
当前线程名:主线程 企图调用B实例的last()方法
当前线程名:副线程 企图调用A实例的last()方法

代码解析:

A类和B类都是线程安全类,以上代码中有两个线程执行,副线程执行体是action函数,主线程线程执行体是init()函数(主程序调用init()函数)。在action中B对象调用bar()方法,进入bar()方法之前,该线程对B对象的Lock加锁,并让副线程暂停0.2秒,此次CPU切换到另一个线程,让A对象执行foo()方法,进入foo()方法之前,该线程对A对象执行foo()方法,进入foo()方法之前,该线程对A对象的Lock加锁,主线程也暂停0.2秒。接下来副线程会先醒过来,继续向下执行,企图调用A对象的last()方法——在执行该方法之前,必须先对A对象的Lock加锁,而此时主线程正保持着A对象的Lock锁定,所以副线程被阻塞。然后主线程也醒过来,继续向下执行,企图调用B对象的last()方法,同样在执行该方法之前,必须先对B对象的Lock加锁,而此时副线程并没有释放B对象的Lock锁定,于是主线程也被阻塞,于是就出现了主线程保持着A对象的锁,对待对B对象加锁,而副线程保持着B对象的锁,等待对A对象加锁,两个线程互相等待对方先释放锁,于是就出现了死锁。

避免死锁常见方法:

● 避免多次锁定:尽量避免同一个线程对多个Lock进行锁定。

● 具有相同的加锁顺序:如果多个线程需要对多个Lock进行锁定,则应该保证它们以相同的顺序请求加锁。如以上代码中,主线程先对A对象的Lock加锁,再对B对象的Lock加锁;而副线程则先对B对象的Lock加锁,再对A对象的Lock加锁。这种加锁顺序很容易形成嵌套锁定,进而导致死锁。如果让主线程、副线程都按照相同的顺序加锁,就可以避免这个问题。

● 使用定时锁:程序在调用acquire()方法加锁时,指定timeout参数,设置指定时间自动释放对Lock的锁定。

● 死锁检测:死锁检测是一种依靠算法机制来实现的死锁预防机制,它主要针对那些不可能实现按序加锁,也不能使用定时锁的场景。

 

六、线程通信

通常情况下程序无法准确控制线程的轮换执行,如果有需要,Python可通线程通信来保证线程协调运行。

(一)使用Condition实现线程通信

使用Condition可以让那些已经得到Lock对象却无法继续执行的线程释放Lock对象,Condition对象也可以唤醒其他处于等待状态的线程。

组合使用Condition对象与Lock对象可以为每个对象提供多个等待集(wait-set)。也就是说,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等待池中等待的所有线程并通知它们。

Condition可以理解为更加高级的的锁,能处理一些复杂的同步问题。

threading.Condition()可以创建一把资源锁(默认是RLock)。

import threading
import time


def TestA():
    cond.acquire()
    print('鲁班:有个残血,追吗?')
    cond.wait()
    print('鲁班:好的!')
    cond.notify()
    cond.release()


def TestB():
    time.sleep(2)
    cond.acquire()
    print('亚瑟:等我一起追。')
    cond.notify()
    cond.wait()
    print('亚瑟:我到了,一起追!')


cond = threading.Condition()
testA = threading.Thread(target=TestA)
testB = threading.Thread(target=TestB)
testA.start()
testB.start()
testA.join()
testB.join()

再来看一个特殊的例子,假设现在要求存款者和取钱者不断地重复存款、取钱动作,但是要求每当存款者将钱存入指定账户后,取钱者立即取出这笔钱。不允许存款者连续两次存钱,也不允许取钱者连续两次取钱。

可以通过一个旗标来标识账户中是否已有存款,当旗标为False时,表明帐户中没有存款,存款者线程可以向下执行,直到存完钱后将旗标置为True,同时调用Condition的notify()方法唤醒其他线程,如果旗标为True,就调用Condition的wait()方法让该线程等待。对我们前面的存取款的例子进行修改后得到如下代码:

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提供gtetter方法
    def getBalance(self):
        return self._balance

    # 提供一个线程安全的draw()方法来完成取钱操作
    def draw(self, draw_amount):
        # 加锁
        self.cond.acquire()
        try:
            # 如果旗标为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))
                # 取钱结束,将旗标置为False
                self._flag = False
                # 唤醒其他线程
                self.cond.notify_all()
        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("帐户余额为:" + str(self._balance))
                # 将表明帐户中是否已有存款的旗标设为True
                self._flag = True
                # 唤醒其他线程
                self.cond.notify_all()
        # 使用finally释放锁
        finally:
            self.cond.release()
import threading

import Account


# 定义一个函数,模拟重复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.Account("123456", 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()

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

在queue模块下提供了几个阻塞队列,这些队列主要用于实现线程通信。

queue模块下主要提供了三个类,代表三种队列:

● queue.Queue(maxsize=0):代表FIFO(先进先出)的常规队列,maxsize可以限制队列的大小,如果队列的大小达到队列上限,就会加锁,再次加入元素时会先被阻塞,知道队列中的元素被消费。如果将maxsize设置为0或负数,则该队列的大小就是无限制的。

● queue.LifoQueue(maxsize=0):代表LIFO(后进先出)的队列,与Queue的quiet就是出队列的顺序不同。

● PriorityQueue(maxsize=0):代表优先级队列,优先级最小的元素先出队列。

以上三个队列的属性和方法基本相同,都提供如下属性和方法:

● Queue.qsize():返回队列的实际大小,也就是该队列中包含几个元素。

● Queue.empty():判断队列是否为空。

● Queue.full():判断队列是否已满。

● Queue.put(item, block=True, timeout=None):向队列中放入元素。如果队列已满,且block参数为True(阻塞),当前线程被阻塞,timeout指定阻塞时间,如果将timeout设置为None,则代表一直阻塞,知道该队列的元素被消费;如果队列已满,且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。

import queue

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

运行以上程序,会发现程序会在试图放入第三个元素时被阻塞,我们可以利用这个特性来实现线程通信,具体代码如下:

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.2)
        # 尝试取出元素,如果队列已空,则线程被阻塞
        t = bq.get()
        print(threading.current_thread().name + "消费者消费[{0}]元素完成!".format(t))


# 创建一个容量为1的Queue
bq = queue.Queue(maxsize=1)
# 启动sane生产者线程
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()

 

运行以上程序可以看到,三个生产者线程启动时,由于队列长度为1,所以,三个生产者线程无法连续放入元素,必须等待消费者线程取出一个元素后,其中的一个生产者线程才能放入一个元素。

(三)使用Event控制线程通信

Event是一种非常简单的线程通信机制:一个线程发出一个Event,另一个线程可通过该Event被触发。

Event本身管理一个内部旗标,程序可以通过Event的set()方法将该旗标设置为True,也可以调用clear()方法将该旗标设置为False。程序可以调用wait()方法来阻塞当前线程,直到Event的内部旗标被设置为True。

Event提供了如下方法:

● is_set():该方法返回Event的内部旗标是否为True。

● set():该方法将会把Event的内部旗标设置为True,并唤醒所有出于等待状态的线程。

● clear():该方法将Evnet的内部旗标设置为False,通常接下来会调用wait()方法来阻塞当前线程。

● wait(timeout=None):该方法会阻塞当前线程。

用法演示:

import threading
import time

event = threading.Event()


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


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

以上程序以cal()函数为target,创建并启动了两个线程。由于cal()函数在①号代码处调用了Event的wait()方法,因此两个线程执行到①号代码处都会进入阻塞状态;即使主线程在②号代码处被阻塞,两个子线程也不会向下执行。直到住程序执行到最后一行时,调用了set()方法将Event的内部旗标设置为True,并唤醒所有等待线程,这两个子线程才能向下执行。

如果结合Event的内部旗标,同样可以实现前面的Account的生产者—消费者效果:存钱线程(生产者)存钱之后,必须等待取钱线程(消费者)取钱之后才能继续向下执行。

Event本身并不带Lock对象,因此,要实现线程同步,还需要额外的Lock对象。

使用Event改写后的Account:

import threading


class Account:
    # 定义构造器
    def __init__(self, account_no, blance):
        # 封装帐户编号和帐户余额两个成员变量
        self.account_no = account_no
        self._blance = blance
        self.lock = threading.Lock()
        self.event = threading.Event()

    # 因为帐户余额不允许随便修改,所以值为self._blance提供getter方法
    def getBlance(self):
        return self._blance

    # 提供一个线程安全的draw()方法来完成取钱操作
    def draw(self, draw_amount):
        # 加锁
        self.lock.acquire()
        # 如果Event的内部旗标为True,则表明帐户中已有人存钱进去
        if self.event.is_set():
            # 执行取钱操作
            print(threading.current_thread().name + "取钱:" + str(draw_amount))
            self._blance -= draw_amount
            print("帐户余额为:" + str(self._blance))
            # 将Event的内部旗标设置为False
            self.event.clear()
            # 释放锁
            self.lock.release()
            # 阻塞当前线程
            self.event.wait()
        else:
            # 释放锁
            self.lock.release()
            # 阻塞当前线程
            self.event.wait()

    def deposit(self, deposit_amount):
        # 加锁
        self.lock.acquire()
        # 如果Event的内部旗标为False,则表明帐户中还没有人存钱进去
        if not self.event.is_set():
            # 执行存款操作
            print(threading.current_thread().name + "存钱:" +
                  str(deposit_amount))
            self._blance += deposit_amount
            print("帐户余额为:" + str(self._blance))
            # 将Event的内部旗标设置为True
            self.event.set()
            # 释放锁
            self.lock.release()
            # 阻塞当前进程
            self.event.wait()
        else:
            # 释放锁
            self.lock.release()
            # 阻塞当前进程
            self.event.wait()

七、线程池

由于涉及与操作系统的交互,所以系统启动一个新线程的成本较高。使用线程池可以很好的提升性能。

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

使用线程池可以有效地控制系统中并发线程的数量。

(一)使用线程池

Python线程池的基类是concurrent.futures模块中的Executor,该类提供两个子类,ThreadPoolExecutor和ProcessPoolExecutor,其中ThreadPoolExecutor用于创建线程池,ProcessPoolExcutor用于创建进程池。

使用线程池/进程池来管理并发编程,只需将相应的task函数提交给线程池/进程池即可。

Executor提供如下常用方法

● submit(fn,*args, **kwargs):将fn函数提交给线程池。*args代表传递给fn函数的参数,**kwargs代表以关键字参数的形式为fn函数传入参数。

● map(func, *iterables, timeout=None, chunksize=1):该函数类似于全局函数map(func, *iterable),只是该函数将会启动多个线程,以异步方式立即对iterables执行map处理。

● shutdown(wait=True):关闭线程池。

程序将task函数通过submit方法提交给线程池后,该方法会放回一个Future对象,用于获取线程任务函数的返回值。

Future提供了如下方法:

● cancel():取消该Future代表的线程任务。如果该任务正在执行,不可取消,则该方法返回False;否则,程序会取消该任务,并返回True。

● cancelled():返回Future代表的线程任务是否被成功取消。

● running():如果该Futrue代表的线程任务正在执行、不可被取消,该方法返回True。

● done():如果该Future代表的线程任务被成功取消或执行完成,则该方法返回True。

● result(timeout=None):获取该Futrue代表的线程任务最后返回的结果。如果Future代表的线程任务还未完成,该方法将会阻塞当前线程,其中timeout参数指定最多阻塞多少秒。

● exception(timeout=None):获取该Future代表的线程任务所引发的异常。如果该任务成功完成,该方法返回None。

● add_done_callback(fn):为该Future代表的线程任务注册一个“回调函数”,当该任务成功完成时,程序会自动触发该fn函数。

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

使用线程池的步骤:

① 调用ThreadPoolExecutor类的构造器创建一个线程池。

② 定义一个普通函数作为线程任务。

③ 调用ThreadPoolExecutor对象的submit()方法来提交线程任务。

④ 当不想提交任何任务时,调用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 + '\t' + 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())
# 查看future1代表的任务返回的结果
print(future1.result())
# 查看future2代表的任务返回的结果
print(future2.result())
# 关闭线程池
pool.shutdown()

当程序使用Future的result()方法来获取结果时,该方法会阻塞当前线程,如果没有指定timeout参数,当前线程将一直处于阻塞状态,知道Future代表的任务返回。

(二)获取执行结果

如果程序不希望直接调用result()方法阻塞线程,则可通过调用Future的add_done_callback()方法来添加回调函数,该回调函数形如fn(future)。当线程任务完成后,程序会自动触发该回调函数,并将对应的Future对象作为参数传递给回调函数。

from concurrent.futures import ThreadPoolExecutor
import threading


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


# 创建一个包含两个线程的线程池
with ThreadPoolExecutor(max_workers=2) as pool:
    # 向线程池中提交一个任务,50会作为action()函数的参数
    future1 = pool.submit(action, 50)
    # 向线程池中再提交一个任务,100会作为action()函数的参数
    future2 = pool.submit(action, 100)

    def get_result(future):
        print(future.result())

    # 为future1添加线程完成的回调函数
    future1.add_done_callback(get_result)
    # 为future2添加线程完成的回调函数
    future2.add_done_callback(get_result)
    print("------------------------")

 Executor的map(func, *iterables, timeout=None, chunksize=1)方法会以并发方式来执行func函数,相当于启动len(iterables)个线程,并收集每个线程的执行结果。

from concurrent.futures import ThreadPoolExecutor
import threading


# 定义一个函数作为线程任务函数
def action(max):
    my_sum = 0
    for i in range(max):
        print(threading.current_thread().name + '\t' + 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("-----------------")
    for r in results:
        print(r)

使用map()方法来启动线程,并收集线程的执行结果,不仅具有代码简单的优点,而且虽然程序会以并发方式来执行action()函数,但最后收集的action()函数的执行结果,依然与传入参数的结果保持一致,也就是说,上面results的第一个元素是action(50)的结果,第二个是action(100)的结果,第三个是action(100)的结果。

 

八、线程相关类

(一)线程局部变量

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("{0} mydata.x 的值为:{1}".format(threading.current_thread().name,
                                            mydata.x))


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

执行结果:

ThreadPoolExecutor-0_0 mydata.x 的值为:0
ThreadPoolExecutor-0_0 mydata.x 的值为:1
ThreadPoolExecutor-0_0 mydata.x 的值为:3
ThreadPoolExecutor-0_0 mydata.x 的值为:6
ThreadPoolExecutor-0_0 mydata.x 的值为:10
ThreadPoolExecutor-0_0 mydata.x 的值为:15
ThreadPoolExecutor-0_0 mydata.x 的值为:21
ThreadPoolExecutor-0_0 mydata.x 的值为:28
ThreadPoolExecutor-0_0 mydata.x 的值为:36
ThreadPoolExecutor-0_0 mydata.x 的值为:45
ThreadPoolExecutor-0_1 mydata.x 的值为:0
ThreadPoolExecutor-0_1 mydata.x 的值为:1
ThreadPoolExecutor-0_1 mydata.x 的值为:3
ThreadPoolExecutor-0_1 mydata.x 的值为:6
ThreadPoolExecutor-0_1 mydata.x 的值为:10
ThreadPoolExecutor-0_1 mydata.x 的值为:15
ThreadPoolExecutor-0_1 mydata.x 的值为:21
ThreadPoolExecutor-0_1 mydata.x 的值为:28
ThreadPoolExecutor-0_1 mydata.x 的值为:36
ThreadPoolExecutor-0_1 mydata.x 的值为:45

Process finished with exit code 0

以上程序中定义了一个threading.local变量,程序将会为每个线程各创建一个该变量的副本。如果两个线程共享同一个mydata变量,将会看到mydata.x最后会累加到90。但由于mydata是threading.local变量,因此程序会为每个线程都创建一个该变量的副本,所以将会看到两个线程的mydata.x最后都累加到45.

线程局部变量和其他同步机制一样,都是为了解决多线程中对共享资源的访问冲突的。

普通同步机制中,通过为对象加锁来实现对共享资源的安全访问,线程局部变量将需要并发访问的资源复制多份,从而提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的整个变量放到线程局部变量中,或者把该对象中与线程相关的状态放入线程局部变量中保存。

线程局部变量并不能替代同步机制,两者面向的问题领域不同。

同步机制为了同步多个线程对共享资源的并发访问,是多个线程之间进行通信的有效方式。

线程局部变量为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源的竞争。

如果多个线程之间需要共享资源以实现线程通信,则使用同步机制;如果仅仅需要隔离多个线程之间的共享冲突,则可以使用线程局部变量。

(二)定时器

Thread.Timer子类可以用于控制指定函数在特定时间内执行一次。

from threading import Timer


def hello():
    print("hello, world!")


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

Timer只能控制函数在指定时间内执行一次,如果要使用Timer控制函数多次重复执行,需要再执行下一次调度。

Timer对象的cancel()函数可以取消Timer调度。

from threading import Timer
import time

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


def print_time():
    print("当前时间:{0}".format(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.monotonic来生成时间戳。

● delayfunc:该参数指定阻塞程序的函数,默认使用time.sleep函数来阻塞程序。

sched.scheduler调度器支持如下常用属性和方法:

● scheduler.enterabs(time, priority, action, argument=(), kwargs={}):指定在time时间点执行action函数,argument和kwargs用于向action函数传入参数,argument使用位置参数的形式传入参数,kwargs使用关键字参数的形式传入参数。该方法返回一个event,它可以作为cancel()方法的参数用于取消调度。priority参数指定该任务的优先级,当在同一个时间点有多个任务需要执行时,优先级高(值越小代表优先级越高)的任务会优先执行。

● scheduler.enter(delay, priority, action, argument=(), kwargs={}):该方法与enterabs方法基本相同,只是delay参数用于指定多少秒之后执行action任务。

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

● scheduler.empty():判断当前调度器的调度队列是否为空。

● scheduler.run(blocking=True):运行所有需要调度的任务。如果调用该方法的blocking参数为True,该方法将会阻塞线程,知道所有被调度的任务都执行完成。

● scheduler.queue:该只读属性返回该调度器的调度队列。

import sched, time

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


# 定义被调度的函数
def print_time(name='default'):
    print("{0}的时间:{1}".format(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())

执行结果:

主线程, Thu Nov 14 00:24:00 2019
关键字参数的时间:Thu Nov 14 00:24:05 2019
位置参数的时间:Thu Nov 14 00:24:05 2019
default的时间:Thu Nov 14 00:24:10 2019
主线程: Thu Nov 14 00:24:10 2019

九、多进程

(一)使用fork创建新进程

Python的os模块提供了一个fork()犯法,可以fork出来一个子进程。

fork()方法的作用是:程序会启动两个进程(一个是父进程,一个是frok出来的子进程)来执行从os.fork()开始的所有代码。fork()方法不需要参数,它有一个返回值,该返回值表明是哪个进程在执行。

● 如果fork()方法返回0,则表明是fork出来的子进程在执行。

● 如果fork()方法返回非0,则表明是父进程在执行,该方法返回fork()出来的子进程的进程ID。

import os

print('父进程({0})开始执行'.format(os.getpid()))
# 开始fork一个子进程
# 从这行代码开始,下面的代码都会被两个进程执行
pid = os.fork()
print("进程进入:{0}".format(os.getpid()))
# 如果pid为0,则表明是子进程
if pid == 0:
    print("子进程,其ID为({0}),父进程ID为({1})".format(os.getpid(), os.getppid()))
else:
    print('我({0})创建的子进程ID为({1})。'.format(os.getpid(), pid))
print('进程结束:{0}'.format(os.getpid()))

 

windows不支持fork()方法,以上程序在Windows系统上运行会报错。

在实际编程中,程序可通过fork()方法来创建一个子进程,然后通过判断fork()方法的返回值来确定程序是否正在执行子进程,也就是把需要并发执行的任务放在if pid==0:的条件体中,这样就可以启动多个子进程来执行并发任务。

(二)使用multiprocessing.Process创建新进程

Python在multiprocessing模块下提供了Process来创建新进程。

使用Process创建新进程有两种方式:

1. 以指定函数作为target,创建Process对象。

2. 继承Process类,并重写它的run()方法来创建进程类,程序创建Process子类的实例作为进程。

Process类有如下方法和属性:

● run():重写该方法可以实现进程的执行体。

● start():启动进程。

● join([timeout]):该方法类似于线程的join()方法,当前进程必须等待被join的进程执行完成才能向下执行。

● name:该属性用于设置或访问进程的名字。

● is_alive():判断进程是否还活着。

● daemon:该属性用于判断或设置进程的后台状态。

● pid:返回进程的ID。

● authkey:返回进程的授权key。

● terminate():中断该进程。

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

import multiprocessing
import os


# 定义一个普通的action函数,该函数准备作为进程执行体
def action(max):
    for i in range(max):
        print('({0})子进程(父进程:{1}):{2:d}'.format(os.getpid(), os.getppid(), i))


if __name__ == '__main__':
    # 下面是主程序(也就是主进程)
    for i in range(100):
        print("({0})主进程:{1:d}".format(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('主进程执行完成!')

※通过multiprocessing.Process来创建并启动进程时,程序必须先判断if __name__ == '__main__'。否则可能引发异常。

运行以上代码,程序将启动三个进程,一个主进程和程序启动的两个子进程。由于程序调用了mp2.join(),因此主进程必须等mp2进程安城后才能向下执行。

b、继承Process类创建子进程。

继承Process类创建子进程的步骤:

① 定义继承Process的子类,重写其run()方法准备作为进程执行体

② 创建Process子类的实例。

③ 调用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("({0})子进程(父进程:({1}):{2:d}".format(os.getpid(), os.getppid(),
                                                   i))


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

(三)Context和启动进程的方式

根据平台的支持,Python支持三种启动进程的方式。

1. spawn:父进程会启动一个全新的Python解释器进程。在这种方式下,子进程只能继承那些处理run()方法所必须的资源。典型地,那些不必要的文件描述器和handle都不会被继承。使用这种方式来启动进程,其效率比使用fork和forkserver方式要低得多。

Windows只支持使用spawn方式来启动进程,因此在Windows平台上默认使用这种方式来启动进程。

2. fork:父进程使用os.fork()来启动一个Python解释器进程。在这种方式下,子进程会继承父进程的所有资源,因此子进程基本等效于父进程。这种方式只在UNIX平台上有效。UNIX平台默认使用这种方式来启动进程。

3. forkserver:如果使用这种方式来启动进程,程序将会启动一个服务器进程。在以后的时间内,当程序再次请求启动新进程时,父进程都会连接到该服务器进程,请求由服务器进程来fork新进程。通过这种方式启动的进程不需要从父进程继承资源。这种方式只在UNIX平台上有效。

multiprocessing模块提供了一个set_start_method()函数用于设置启动进程的方式——必须将这行设置代码放在所有与多进程有关的代码之前。

import multiprocessing
import os


def foo(q):
    print('被启动的新进程:({0})'.format(os.getpid()))
    q.put('Python')


if __name__ == '__main__':
    # 设置使用fork方式启动进程
    multiprocessing.set_start_method('fork')
    q = multiprocessing.Queue()
    # 创建进程
    mp = multiprocessing.Process(target=foo, args=(q,))
    # 启动进程
    mp.start()
    # 获取队列中的消息
    print(q.get())
    mp.join()

还可以利用get_context()方法来获取Context对象,调用该方法时传入spawn、fork或forkserver字符串。

Context拥有和multiprocessing相同的API,因此程序可以通过Context来创建并启动进程。

import multiprocessing
import os


def foo(q):
    print('被启动的新进程:({0})'.format(os.getpid()))
    q.put('Python')


if __name__ == '__main__':
    # 设置使用fork方式启动进程,并获取Context对象
    ctx = multiprocessing.get_context('fork')
    # 接下来就可以使用Context对象来代替multiprocessing模块
    q = ctx.Queue()
    # 创建进程
    mp = ctx.Process(target=foo, args=(q,))
    # 启动进程
    mp.start()
    # 获取队列中的消息
    print(q.get())
    mp.join()

(四)使用进程池管理进程

程序可以通过multiprocessing模块的Pool()函数创建进程池,进程池实际上是multiprocessing.pool.Pool类。

进程池常用方法:

● apply(func[, args[, kwds]]):将func函数提交给进程池处理。其中args代表传递给func的位置参数,kwds代表传递给func的关键字参数。该方法会阻塞直到func函数执行完成。

● apply_async(func[, args[, kwds[, callback[, error_callback]]]]):这是apply()方法的异步版本,该方法不会阻塞。其中callback指定func函数完成后的回调函数,error_callback指定func函数出错后的回调函数。

● map(func, iterable[, chunksize]):类似于全局函数map(),使用新进程对iterable的每一个元素执行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 time
import os


def action(name='default'):
    print('({0})进程正在执行,参数为:{1}'.format(os.getpid(), name))
    time.sleep(3)


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()

运行结果:

(25656)进程正在执行,参数为:default
(24836)进程正在执行,参数为:位置参数
(28448)进程正在执行,参数为:关键字参数
import multiprocessing
import os


# 定义一个准备作为进程任务的函数
def action(max):
    my_sum = 0
    for i in range(max):
        print('({0})进程正在执行:{1:d}'.format(os.getpid(), i))
        my_sum += i
    return my_sum


if __name__ == '__main__':
    # 创建一个包含4个进程的进程池
    with multiprocessing.Pool(processes=4) as pool:
        # 使用进程执行map计算
        # 后面元组有3个元素,因此程序启动3个进程来执行action函数
        results = pool.map(action, (50, 100, 150))
        print('----------------------')
        for r in results:
            print(r)

(五)进程通信

Python为进程通信提供了两种机制

1. Queue:一个进程向Queue中放入数据,另一个进程从Queue中读取数据

2. Pipe:Pipe代表连接两个进程的管道。程序在调用Pipe()函数时会产生两个连接端,分别交给通信的两个进程,进程即可以从该连接端读取数据,也可以向连接端写入数据。

a、使用Queue实现进程通信

进程的Queue与线程的Queue类似,都提供了qsize()、empty()、full()、put()、put_nowait()、get()、get_nowait()等方法。

import multiprocessing


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


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

执行结果:

(14352)进程开始取出数据...
(6196)进程开始放入数据...
Python

b、使用Pipe实现进程通信

程序调用multiprocessing.Pipe()函数来创建一个管道,该函数会返回两个PipeConnection对象,代表管道的两个连接端。

PipeConnection对象包含如下常用方法:

● send(obj):发送一个obj给管道的另一端,另一端使用recv()方法接收。该obj必须是可pickable的(Python的序列化机制),如果该对象序列化之后超过32MB,则可能会引发ValueError异常。

● recv():接收另一端通过send()方法发送过来的数据。

● fileno():关于连接所使用的文件描述器。

● close():关闭连接

● poll([timeout]):返回连接中是否还有数据可以读取。

● send_bytes(buffer[, offset[, size]]):发送字节数据。如果没有指定offset、size参数,则默认发送buffer字节串的全部数据;如果指定了offset和size参数,则只发送buffer字节串中从offset开始、长度为size的字节数据。通过该方法发送的数据,应该使用recv_bytes()或recv_bytes_into方法接收。

● recv_bytes([maxlength]):接收通过send_bytes()方法发送的数据,maxlength指定最多接收的字节数,该方法返回接收到的字节数据。

● recv_bytes_into(buffer[, offset]):功能同recv_bytes()方法类似,只是该方法将接收到的数据放在buffer中。

import multiprocessing


def f(conn):
    print('({0})进程开始发送数据...'.format(multiprocessing.current_process().pid))
    # 使用conn发送数据
    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('({0})进程开始接收数据...'.format(multiprocessing.current_process().pid))
    # 通过conn读取数据
    print(parent_conn.recv())    # Python
    p.join()

运行结果:

(31524)进程开始接收数据...
(27492)进程开始发送数据...
Python

习题:

1. 启动3个线程打印递增的数字,控制线程l打印1,2,3,4,5(每行都打印线程名和一个数字〉,线程2打印6,7,8,9,10 ,线程3打印11,12,13,14,15; 接下来再由线程l打印16,17,18,19,20……依此类推,直到打印75。

from concurrent.futures import ThreadPoolExecutor
import threading


# 新建一个类控制线程锁
class MyThread:

    def __init__(self):
        # 当前打印的值
        self.number = 0
        # 控制应由哪个线程执行打印任务
        self.state = 1
        # 使用condition来进行线程间通信
        self.cond = threading.Condition()

    # 打印方法,连续打印5个数字以后就退出当前线程,把执行权限交给下一个线程
    def my_print(self, thread_num):
        try:
            # 为当前线程加锁
            self.cond.acquire()
            # 如果当前线程不是应该执行打印任务的线程,则阻塞当前线程
            while self.state != thread_num:
                self.cond.wait()
            # 打印5个连续数值:
            for i in range(5):
                self.number += 1
                print("thread{0} : {1}".format(thread_num, self.number))
            # 每打印5个数字后,将thread_num即state值加1,控制由下一个线程来执行打印任务
            self.state = self.state % 3 + 1
            # 唤醒所有线程
            self.cond.notify_all()
        finally:
            # 释放锁
            self.cond.release()


# 线程执行体
def action(mt, thread_num):
    # 控制每个线程要执行MyThread对象的my_print()方法5次
    for i in range(5):
        mt.my_print(thread_num)


# 创建MyThread类对象
mt = MyThread()
# 创建一个包含三个线程的线程池
with ThreadPoolExecutor(max_workers=3) as pool:
    # 启动3个线程
    for i in range(3):
        # 使用线程池启动3个线程
        pool.submit(action, mt, i + 1)

2. 编写两个线程,其中一个线程打印1~52;另一个线程打印A~Z,打印顺序是12A34B56C … 5152Z。该练习题需要利用多线程通信的知识。

import threading, queue

# 创建一个只有1个元素的队列
bq = queue.Queue(1)
# 创建线程锁
lock = threading.RLock()


def action1(bq):
    for i in range(1, 53, 2):
        # 向队列中放入元素,因为队列只有一个元素,因此放入元素后该线程被阻塞
        bq.put(i)
        print(i, end='')
        print(i + 1, end='')


def action2(bq):
    for i in range(26):
        # 从队列中取出元素,取出元素后,队列为空当前线程被阻塞
        bq.get()
        print(chr(65 + i), end='')


# 创建并启动第一个线程
t1 = threading.Thread(target=action1, args=(bq,))
t1.start()
# 创建并启动第二个线程
t2 = threading.Thread(target=action2, args=(bq,))
t2.start()

3. 有4个线程1,2,3,4。线程l的功能是输出l,线程2的功能是输出2,依此类推。现在有4个文件A,B,C,D ,初始都为空。让4个文件最后呈现出如下内容:

A:1 2 3 4 1 2...

B: 2 3 4 1 2 3...

C: 3 4 1 2 3 4...

D: 4 1 2 3 4 1...

from concurrent.futures import ThreadPoolExecutor
import threading
import time, os
from pathlib import Path


# 创建文件写入类
class WriteFile:

    def __init__(self):
        # 当前线程ID
        self.current_thread_num = 1
        # 写入文件总数
        self.write_count = 0

    # 创建函数向文件写入数字
    def write_num(self, value):
        # 生成文件位置
        with open(self.current_file_name() + ".txt", 'a+') as f:
            f.write(value + " ")
            print(
                "ThreadNum={0} is executing. {1} is written into file: {2}.txt \n"
                .format(self.current_thread_num, value,
                        self.current_file_name()))
            self.write_count += 1
            self.current_thread_num = int(value)
            self.next_thread_num()

    # 获取当前写入文件的文件名
    def current_file_name(self):
        '''判断接下来要写入哪个文件'''
        tmp = self.write_count % 4
        name_map = {0: 'A', 1: 'B', 2: 'C', 3: 'D'}
        return name_map[tmp]

    # 获取下一个进程的ID
    def next_thread_num(self):
        if self.write_count % 4 == 0:
            if self.current_thread_num < 3:
                self.current_thread_num += 2
            else:
                self.current_thread_num = (self.current_thread_num + 2) % 4
        else:
            if self.current_thread_num == 4:
                self.current_thread_num = 1
            else:
                self.current_thread_num += 1


wf = WriteFile()
# 创建Condition对象,用于线程间通信
wf.cond = threading.Condition()
# 如果文件已经存在,先将文件删除
for f in ('A', 'B', 'C', 'D'):
    if Path(f + '.txt').exists():
        os.remove(f + '.txt')


# 创建线程体函数
def action(value):
    try:
        # 向每个文件写入6个数字
        for i in range(6):
            try:
                wf.cond.acquire()
                # 保证要写入的值必须与当前线程的id相同
                while int(value) != wf.current_thread_num:
                    wf.cond.wait()
                wf.write_num(value)
                wf.cond.notify_all()
            finally:
                wf.cond.release()
    except Exception as e:
        print("异常{0}".format(e))


# 创建一个包含4个线程的线程池
with ThreadPoolExecutor(max_workers=4) as pool:
    # 使用线程池启动4个线程
    for i in range(4):
        pool.submit(action, str(i + 1))

 

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值