1. python的多线程
python在一般情况下都是同步执行,或者说顺序执行代码,然而有时候我们可能会需要让我们的python程序能够异步执行。例如发送邮件时需要进行网络通信,因为存在网络延迟,我们会被迫地等待一段时间,这个时候我们可能就会希望剩余代码的执行不会被发送邮件这一个过程阻塞,又或者我们希望多个发送邮件的过程可以并行执行。
然而令人悲伤的是python是无法做到同时做多件事的,因为python存在全局解释锁(Global Interperter Lock) - GIL,在一个python进程中只允许一个线程获得锁,而只有获得锁的进程才可以继续执行。这就像一个工人团队里,只有一把锤子,只有拿到锤子的工人才可以继续干活。但是我们可以在一个线程阻塞的时候,释放GIL,让同一进程的其他线程可以获得GIL并执行,这就是python多线程的本质。
当我们了解了python多线程的本质,就会开始需要去考虑一点--python多线程的使用场景。python多线程适用于IO密集型场景,例如数据读取,网络通信等,但不适用于计算密集型场景,例如处理一系列逻辑规则或者数据运算。因为当一个线程的执行过程不存在多余的等待时间,然后被切换执行,是不会提高效率的。
2. 从零开始的python多线程编程
在了解python多线程的一些基本概念之后,我们就可以开始考虑应用它。现在让我们开始使用threading,threading是python处理线程的标准库,而使用threading的基本步骤可以分为:
- 引入threading模块
- 创建threading.Thread实例化对象,thread.Thread(target=YourFunction, args=ArgumentsToTheFunction)
- 使用start函数启动线程
- 使用对完成任务的线程使用join函数
例如,我们有一个发送邮件的函数,发送邮件耗时为2.5秒, 我们想给A和B发送邮件,以前我们可能会这样写:
import time
def send_email(user):
'''
发送邮件模拟函数
'''
print("Sending email to {}...".format(user))
time.sleep(2.5)
print("Finish sending email to {}!".format(user))
time_start = time.time()
send_email("A")
send_email("B")
print("We took {} seconds".format(time.time() - time_start))
运行了以上代码后,我们会得到类似以下的结果:
Sending email to A...
Finish sending email to A!
Sending email to B...
Finish sending email to B!
We took 5.004628896713257 seconds
从结果中我们可以看到,这次一共花了两次发送邮件需要的时间,现在我们试试用多线程来提高效率:
import time
from threading import Thread
def send_email(user):
'''
发送邮件模拟函数
'''
print("Sending email to {}...".format(user))
time.sleep(2.5)
print("Finish sending email to {}!".format(user))
time_start = time.time()
thread_a = Thread(target=send_email, args=("A",))
thread_b = Thread(target=send_email, args=("B",))
thread_a.start()
thread_b.start()
thread_a.join()
thread_b.join()
print("We took {} seconds...".format(time.time()-time_start))
运行了以上代码后,我们会得到类似以下的结果:
Sending email to A...
Sending email to B...
Finish sending email to A!
Finish sending email to B!
We took 2.5051839351654053 seconds...
可以看到我们用线程实现了同时给A和B发送邮件,总体花费时间是一次发送邮件的时间,效率提高了一倍!对于以上使用线程的代码中,我们可能大概清晰threading的大部分使用函数,但会对里面的join函数有疑惑——这函数的作用是什么?
在一般情况下,python程序会等待所有线程执行完毕才结束,并且线程和主线程完成顺序是不一定的。如果我们拿掉了join函数,处理器会直接执行主线程代码,当主程序执行完毕之后,可能还有些线程未执行完毕,主程序要等待其完成才会退出。
对于主程序执行完成后可能还有未执行完成的子线程问题,我们有两种解决办法:一,在创建子线程时设定daemon参数为True,daemon为True的线程会在程序要关闭时立刻被kill掉,可以保证线程的及时关闭,但不能保证每个线程的工作都已完成。
方法二是创建子线程时设定daemon参数为False,或者对子线程对象使用join函数,他们两者的效果都是:让处理器优先执行这些线程,执行完毕之后才会去执行剩余代码。
3. 管理更多的线程
3.1 初步尝试
以上我们简单尝试了一下双线程,现在就让我们来试试更多的线程,如果我们需要发送邮件的对象可能不止两个,而是有10个,那我们可以这样写:
import time
from threading import Thread
def send_email(user):
'''
发送邮件模拟函数
'''
print("Sending email to {}...".format(user))
time.sleep(2.5)
print("Finish sending email to {}!".format(user))
time_start = time.time()
thread_list = []
for i in range(10):
user = "user %d" % (i+1)
t = Thread(target=send_email, args=(user,))
thread_list.append(t)
t.start()
for t in thread_list:
t.join()
print("We took {} seconds...".format(time.time()-time_start))
在以上代码中,我们使用了for循环创建了十个线程,运行了以上代码之后,我们会得到类似以下的结果:
Sending email to user 1...
Sending email to user 2...
Sending email to user 3...
Sending email to user 4...
Sending email to user 5...
Sending email to user 6...
Sending email to user 7...
Sending email to user 8...Sending email to user 9...
Sending email to user 10...
Finish sending email to user 1!
Finish sending email to user 2!
Finish sending email to user 3!Finish sending email to user 5!Finish sending email to user 4!
Finish sending email to user 9!Finish sending email to user 8!
Finish sending email to user 10!
Finish sending email to user 7!
Finish sending email to user 6!
We took 2.5120158195495605 seconds...
可以看到我们即使是给十个用户发送了邮件,总体上只花费了一次发送邮件需要的耗时,这就是多线程的神奇之处。但是我们根据通过输出的信息发现,线程的启动是有序的,和我们在代码中编写的顺序保持一致,但是完成的顺序是无序的。这是因为线程的执行顺序完全是由操作系统决定的,并且一般情况下我们无法提前得知线程被执行的顺序。
3.2 使用concurrent.futures管理多线程
其实除了使用for 循环,我们还有更好的方式管理多线程--就是使用concurrent.futures。在python3.2以后支持了标准库concurrent.futures,里面的ThreadPoolExecutor类可以让我们更方便地处理多个线程,使用concurrent.futures.ThreadPoolExecutor的基本步骤可以分为:
- 引入ThreadPoolExecutor类
- 创建ThreadPoolExecutor类的实例对象,如ThreadPoolExecutor(max_workers=MaxThreadsNumber)
- 对创建的线程管理器使用.map(YourFunction, ArgumentsToTheFunction)来绑定线程一起执行的函数和每个线程的参数
- 使用.shutdown函数关闭线程池
现在让我们使用concurrent.futures.ThreadPoolExecutor来优化上面的代码:
from concurrent.futures import ThreadPoolExecutor
import time
def send_email(user):
'''
发送邮件模拟函数
'''
print("Sending email to {}...".format(user))
time.sleep(2.5)
print("Finish sending email to {}!".format(user))
time_start = time.time()
users = range(1,11)
with ThreadPoolExecutor(max_workers=len(users)) as executor:
executor.map(send_mail, users)
print("We took {} seconds...".format(time.time()-time_start))
代码比之前真的简洁了许多,让我们运行一下代码,看看效果是否也是那么优秀:
Sending email to 1...
Sending email to 2...
Sending email to 3...
Sending email to 4...
Sending email to 5...Sending email to 6...
Sending email to 7...
Sending email to 8...Sending email to 9...
Sending email to 10...
Finish sending email to 1!
Finish sending email to 2!
Finish sending email to 3!
Finish sending email to 4!Finish sending email to 5!Finish sending email to 6!Finish sending email to 7!
Finish sending email to 8! Finish sending email to 10! Finish sending email to 9!
We took 2.511277914047241 seconds...
可以看到效果和我们之前的代码是相同的。在代码中,我们之没有使用.shutdown函数,而是使用with语句。使用with语句的好处是,创建的executor在with语句内代码执行完毕之后自动被释放,使用with语句相当于对executor使用了wait参数为True的shutdown函数,可以避免我们忘记使用shutdown函数关闭线程池。对于shutdown函数的wait参数,wait参数为True时这个函数会等待线程执行完毕,并且涉及到的资源都被释放,才会返回,情况类型于之前对所有线程执行了join函数;如果为False,shutdown函数会立刻返回并且执行余下函数,线程执行管理涉及的资源会等线程执行才被释放,程序要退出时要等所有线程执行完毕才会退出,情况类似于之前未对所有线程执行join函数。
3.* 番外篇--使用logging显示日志
在之前的输出信息我们注意到,单纯使用print函数输出的信息十分紊乱,要么该换行不换行,要么行间有空白行,为此,我们可以考虑用python标准库--logging。logging模块主要用于显示程序的执行日志,可以很方便地帮助我们追溯程序执行过程中可能存在的问题。logging可应用的日志级别有5种,每种级别的使用情况如下:
| 等级 | 适用场景 |
|---|---|
| DEBUG | 用于显示软件执行过程中的详细信息,一般用于调试软件中存在的问题 |
| INFO | 用于验证软件是否正常运行 |
| WARNING | 用于提醒一些不寻常的迹象,或者是一些即将会发送的问题(如磁盘空间预警)。但整体来说,软件还是可以正常运行 |
| ERROR | 由于一些更为严重的问题,软件无法执行某些功能 |
| CRITICAL | 用于显示非常严重的问题,如发生了一些问题导致整体程序无法继续运行 |
logging的基本使用步骤有:
- 引入logging模块
- 配置日志显示格式,使用logging.basicCOnfig配置对应级别日志的输出格式
- 根据情况选择使用以下一种函数输入日志
| logger函数 |
|---|
| logging.infologger.debug('debug message') |
| logger.info('info message') |
| logger.warning('warn message') |
| logger.error('error message') |
| logger.critical('critical message') |
现在让我们在用logging模块优化一下我们的日志输出:
from concurrent.futures import ThreadPoolExecutor
import time
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
def send_email(user):
'''
发送邮件模拟函数
'''
logging.info("Sending email to {}...".format(user))
time.sleep(2.5)
logging.info("Finish sending email to {}!".format(user))
time_start = time.time()
users = range(1,11)
with ThreadPoolExecutor(max_workers=len(users)) as executor:
executor.map(send_email, users)
logging.info("We took {} seconds...".format(time.time()-time_start))
如果你运行了以上代码,你会得到类似以下的结果:
10:21:07: Sending email to 1...
10:21:07: Sending email to 5...
10:21:07: Sending email to 3...
10:21:07: Sending email to 4...
10:21:07: Sending email to 6...
10:21:07: Sending email to 2...
10:21:07: Sending email to 7...
10:21:07: Sending email to 8...
10:21:07: Sending email to 9...
10:21:07: Sending email to 10...
10:21:10: Finish sending email to 1!
10:21:10: Finish sending email to 3!
10:21:10: Finish sending email to 5!
10:21:10: Finish sending email to 4!
10:21:10: Finish sending email to 6!
10:21:10: Finish sending email to 2!
10:21:10: Finish sending email to 7!
10:21:10: Finish sending email to 8!
10:21:10: Finish sending email to 9!
10:21:10: Finish sending email to 10!
10:21:10: We took 2.614102602005005 seconds...
日志信息看起来整洁多了,到目前我们对python多线程的使用已经有了一定的了解,但是在往后多线程的使用过程中,我们可能还会遇到更加烦人的问题 -- 竞态争用。
4. 竞态争用
竞态争用发生在多个线程同时共享同一数据或其他资源,竞态争用问题发生率是比较低的,但是一旦出现就会造成让让人预想不到的错误,这种错误不属于代码逻辑错误,是系统执行线程任务造成的,所以即使debug也无法找出错误发生的原因。
竞态争用在一般情况下发生概率较低,但是我们可以模拟个例子,让多个线程同时处理同个数据的情况更加频繁,然后我们就能更加直观地了解竞态争用现象。现在让我们来模拟对数据库中数据进行存取操作:
import time
from concurrent.futures import ThreadPoolExecutor
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
class FakeDatabase:
'''
模拟数据库
'''
def __init__(self):
self.value = 0
def get_value(self):
time.sleep(0.1)
return self.value
def set_value(self, thread_number, value=0):
logging.info("Thread {}: start setting data with value={}".format(thread_number, value))
value_get = self.get_value()
logging.info("Thread {}: get value -- {}".format(thread_number, value))
value = value_get + value
time.sleep(0.1)
self.value = value
logging.info("Thread {}: done, and now value in database is {}".format(thread_number, self.value))
time_start = time.time()
database = FakeDatabase()
with ThreadPoolExecutor(max_workers=10) as executor:
for index in range(10):
executor.submit(database.set_value, thread_number=index+1, value=index)
logging.info("Main: we took {} seconds".format(time.time()-time_start))
logging.info("Main: finally, the value in database is {}".format(database.value))
在以上代码中,我们创建了FakeDatabase类用于模拟从数据库中存取数据,并且在取数据,和存数据的过程中我们都睡眠了0.1秒用于模拟网络延时,在最后的线程执行器执行线程我们用的函数我们换成了submit,submit函数可以绑定线程需要执行的函数,并且传递函数所需要的普通参数及带关键词参数。
在以上的整体代码逻辑中,我们预想的是数据库里的最初值为0,十个线程每个都把自己的index值加给假数据库里的值,那么假数据库里最后的值应该是0+0+1+...+9,即(0+9)*10/2 = 45,然而在我们执行了以上代码后,我们会看到类似以下的结果:
19:25:55: Thread 1: start setting data with value=0
19:25:55: Thread 2: start setting data with value=1
19:25:55: Thread 3: start setting data with value=2
19:25:55: Thread 4: start setting data with value=3
19:25:55: Thread 5: start setting data with value=4
19:25:55: Thread 6: start setting data with value=5
19:25:55: Thread 7: start setting data with value=6
19:25:55: Thread 8: start setting data with value=7
19:25:55: Thread 9: start setting data with value=8
19:25:55: Thread 10: start setting data with value=9
19:25:55: Thread 1: get value -- 0
19:25:55: Thread 2: get value -- 1
19:25:55: Thread 3: get value -- 2
19:25:55: Thread 4: get value -- 3
19:25:55: Thread 5: get value -- 4
19:25:55: Thread 6: get value -- 5
19:25:55: Thread 7: get value -- 6
19:25:55: Thread 8: get value -- 7
19:25:55: Thread 9: get value -- 8
19:25:55: Thread 10: get value -- 9
19:25:55: Thread 2: done, and now value in database is 1
19:25:55: Thread 1: done, and now value in database is 0
19:25:55: Thread 3: done, and now value in database is 2
19:25:55: Thread 4: done, and now value in database is 3
19:25:55: Thread 5: done, and now value in database is 4
19:25:55: Thread 6: done, and now value in database is 5
19:25:55: Thread 7: done, and now value in database is 6
19:25:55: Thread 8: done, and now value in database is 7
19:25:55: Thread 9: done, and now value in database is 8
19:25:55: Thread 10: done, and now value in database is 9
19:25:55: Main: we took 0.2369060516357422 seconds
19:25:55: Main: finally, the value in database is 9
结果竟然是9,与我们预期的完全不同,并且诡异的是每个线程经过计算后假数据库里的值就是每个线程的线程数减一。这中间线程实际执行过程如下图:
从上图我们可以看到当一个线程阻塞(即睡眠时),执行器会去执行另一个线程,每个线程在数据库里的值还未被修改的时候,就轮流读取了数据库的值,并且最后轮流把自己的结果覆盖了数据库的值,基本上每个线程之间互不影响,互不干涉。所以整体上就是每个线程都获取了数据库的最初值,然后加上自己的线程数-1的值,最后轮流把结果值覆盖数据库里的值。
以上就是因为竞态争用而导致的问题,我们为了直观体验,模拟了数据库中的值的存取。而在真实情况中,对数据库的操作并不如此,并且我们在代码中使用sleep()强行让处理器切换执行其他线程,才会有以上的结果。在一般情况下,处理器会在线程任何执行阶段切换执行其他线程,如果我们在进行多线程操作过程中涉及全局变量或其他共享的数据都要特别注意,一旦发送因为竞态争用导致的变量值发生异常,到最后我们很难去确定错误发生的原因的。竞态争用发生的概率很小,但如果是在上百万的迭代过程中,概率小的事件也很可能发生。
5. 竞态争用的解决方法--使用锁
在解决竞态争用的方法中,应用比较广泛的就是使用锁。锁只有一个,拿到锁的线程才可以执行数据的读取、修改及存储,等执行完成这些操作之后把锁释放。锁机制两个关键步骤是锁的获取和释放,锁的释放尤为重要,因为其他线程一直没有获取锁就会进入阻塞状态,然后导致整体程序卡死,这种现象我们称之为死锁。对于python中锁的使用,在我们之前提及的threading库中有个Lock类,threading.Lock类基本使用步骤如下:
- 引入threading.Lock
- 创建threading.Lock的实例对象
- 使用acquire方法获取类
- 使用release方法释放类,防止死锁
其实对于锁的获取和释放,我们可以使用with语句来声明threading.Lock对象,这样锁在with语句块结束后就会自动释放。
接下来我们用threading.Lock来解决我们之前的竞态争用问题:
import time
from concurrent.futures import ThreadPoolExecutor
import logging
import threading
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
class FakeDatabase:
'''
模拟数据库
'''
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def get_value(self):
time.sleep(0.1)
return self.value
def set_value(self, thread_number, value=0):
logging.info("Thread {}: start setting data with value={}".format(thread_number, value))
logging.debug("Thread {} about to get lock".format(thread_number))
with self._lock:
value_get = self.get_value()
logging.info("Thread {}: get value -- {}".format(thread_number, value))
value = value_get + value
time.sleep(0.1)
self.value = value
logging.debug("Thread {} about to release lock".format(thread_number))
logging.info("Thread {}: done, and now value in database is {}".format(thread_number, self.value))
time_start = time.time()
database = FakeDatabase()
with ThreadPoolExecutor(max_workers=10) as executor:
for index in range(10):
executor.submit(database.set_value, thread_number=index+1, value=index)
logging.info("Main: we took {} seconds".format(time.time()-time_start))
logging.info("Main: finally, the value in database is {}".format(database.value))
运行了以上代码后,我们会得到类似以下的结果:
17:26:51: Thread 1: start setting data with value=0
17:26:51: Thread 2: start setting data with value=1
17:26:51: Thread 5: start setting data with value=4
17:26:51: Thread 4: start setting data with value=3
17:26:51: Thread 3: start setting data with value=2
17:26:51: Thread 6: start setting data with value=5
17:26:51: Thread 7: start setting data with value=6
17:26:51: Thread 8: start setting data with value=7
17:26:51: Thread 9: start setting data with value=8
17:26:51: Thread 10: start setting data with value=9
17:26:51: Thread 1: get value -- 0
17:26:51: Thread 1: done, and now value in database is 0
17:26:51: Thread 2: get value -- 1
17:26:52: Thread 2: done, and now value in database is 1
17:26:52: Thread 5: get value -- 4
17:26:52: Thread 5: done, and now value in database is 5
17:26:52: Thread 4: get value -- 3
17:26:52: Thread 4: done, and now value in database is 8
17:26:52: Thread 3: get value -- 2
17:26:52: Thread 3: done, and now value in database is 10
17:26:52: Thread 6: get value -- 5
17:26:52: Thread 6: done, and now value in database is 15
17:26:52: Thread 7: get value -- 6
17:26:53: Thread 7: done, and now value in database is 21
17:26:53: Thread 8: get value -- 7
17:26:53: Thread 8: done, and now value in database is 28
17:26:53: Thread 9: get value -- 8
17:26:53: Thread 9: done, and now value in database is 36
17:26:53: Thread 10: get value -- 9
17:26:53: Thread 10: done, and now value in database is 45
17:26:53: Main: we took 2.0642921924591064 seconds
17:26:53: Main: finally, the value in database is 45
p最后的计算结果终于如我们预期的一样,所有的线程按序地去获取、修改及存储值。在上面的代码中,我们除了使用threading.Lock还用logging.debug来输出部分信息,但是我们在最后的结果中并没有看到。如我们之前提到的,logging.debug是配合调试软件中存在的问题来输出信息的,应该在软件调试时才显示,而不是显示在平时的日志里。     如果我们要显示debug的信息,可以在引入logging模块后,加一句:
logging.getLogger().setLevel(logging.DEBUG)
下面是开启了debug信息显示后的结果:
16:56:58: Thread 1: start setting data with value=0
16:56:58: Thread 1 about to get lock
16:56:58: Thread 2: start setting data with value=1
16:56:58: Thread 2 about to get lock
16:56:58: Thread 4: start setting data with value=3
16:56:58: Thread 3: start setting data with value=2
16:56:58: Thread 3 about to get lock
16:56:58: Thread 6: start setting data with value=5
16:56:58: Thread 6 about to get lock
16:56:58: Thread 4 about to get lock
16:56:58: Thread 8: start setting data with value=7
16:56:58: Thread 8 about to get lock
16:56:58: Thread 10: start setting data with value=9
16:56:58: Thread 7: start setting data with value=6
16:56:58: Thread 5: start setting data with value=4
16:56:58: Thread 9: start setting data with value=8
16:56:58: Thread 10 about to get lock
16:56:58: Thread 7 about to get lock
16:56:58: Thread 5 about to get lock
16:56:58: Thread 1: get value -- 0
16:56:58: Thread 9 about to get lock
16:56:58: Thread 1 about to release lock
16:56:58: Thread 1: done, and now value in database is 0
16:56:58: Thread 2: get value -- 1
16:56:58: Thread 2 about to release lock
16:56:58: Thread 2: done, and now value in database is 1
16:56:59: Thread 3: get value -- 2
16:56:59: Thread 3 about to release lock
16:56:59: Thread 3: done, and now value in database is 3
16:56:59: Thread 6: get value -- 5
16:56:59: Thread 6 about to release lock
16:56:59: Thread 6: done, and now value in database is 8
16:56:59: Thread 4: get value -- 3
16:56:59: Thread 4 about to release lock
16:56:59: Thread 4: done, and now value in database is 11
16:56:59: Thread 8: get value -- 7
16:56:59: Thread 8 about to release lock
16:56:59: Thread 8: done, and now value in database is 18
16:56:59: Thread 10: get value -- 9
16:57:00: Thread 10 about to release lock
16:57:00: Thread 10: done, and now value in database is 27
16:57:00: Thread 7: get value -- 6
16:57:00: Thread 7 about to release lock
16:57:00: Thread 7: done, and now value in database is 33
16:57:00: Thread 5: get value -- 4
16:57:00: Thread 5 about to release lock
16:57:00: Thread 5: done, and now value in database is 37
16:57:00: Thread 9: get value -- 8
16:57:00: Thread 9 about to release lock
16:57:00: Thread 9: done, and now value in database is 45
16:57:00: Main: we took 2.0847480297088623 seconds
16:57:00: Main: finally, the value in database is 45
现在我们就能看到程序执行过程中,每个线程是什么时候获取锁、得到锁。到目前为止,日志真的帮了我们很多,它清楚地让我们看到程序执行的过程,以及每一步执行后的结果,这对我们来说是十分有用的,一旦有问题发生,我们就会很庆幸当初设置了清晰且易于观察的日志。
6. 死锁
p在之前我们简单触及到了一个概念--死锁,我们之前对此简单提及并不是意味着这并不重要。相反,死锁是一个严重的问题,一旦发生就会阻塞进程,可能会导致我们的程序再也不能继续进行下去。
死锁,我们之前已经知道,如果在锁获取后没有及时释放就会导致死锁。即使我们用了release方法,也可能出现锁没有被释放的情况,导致这种情况发生一般会有两种原因......
6.1 意料之外的死锁原因之一 -- 异常与不合理的代码逻辑
- 问题触发的关键: 没有释放锁
- 解决方式: 使用with语句或使用release
如果在锁释放前发生了异常,或者是函数在锁释放前提前返回,程序未能执行到锁释放的那一步,就会导致死锁,让我们来看个例子:
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
l = Lock()
def test(thread_num):
logging.debug("Thread {}: about to acquire lock".format(thread_num))
l.acquire()
logging.debug("Thread {}: got the lock".format(thread_num))
logging.debug("Thread {}: about to release lock".format(thread_num))
raise ValueError("Something went wrong...")
l.release()
logging.debug("Thread {}: released the lock".format(thread_num))
with ThreadPoolExecutor(max_workers=2) as executor:
for i in range(2):
executor.submit(test, i+1)
执行了以上代码后,我们会看到类似以下的结果:
17:20:58: Thread 1: about to acquire lock
17:20:58: Thread 1: got the lock
17:20:58: Thread 1: about to release lock
17:20:58: Thread 2: about to acquire lock
在以上代码中我们创建了两个线程,每个线程中都去尝试获取锁,但是在锁释放之前从我们提前故意引发ValueError,导致了锁没有及时被释放。基于以上的问题,我们应该考虑使用with语句,可以避免因忘记写release方法或者因代码逻辑有误等原因导致的死锁,让我们再试试使用with语句改写以上代码:
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
def test(thread_num):
logging.debug("Thread {}: about to acquire lock".format(thread_num))
with Lock():
logging.debug("Thread {}: got the lock".format(thread_num))
logging.debug("Thread {}: about to release lock".format(thread_num))
raise ValueError("Thread {}: something went wrong...".format(thread_num))
logging.debug("Thread {}: released the lock".format(thread_num))
with ThreadPoolExecutor(max_workers=2) as executor:
for i in range(2):
executor.submit(test, i+1)
运行了以上代码后我们会看到类似以下的结果:
18:50:06: Thread 1: about to acquire lock
18:50:06: Thread 1: got the lock
18:50:06: Thread 1: about to release lock
18:50:06: Thread 2: about to acquire lock
18:50:06: Thread 2: got the lock
18:50:06: Thread 2: about to release lock
可以看到,线程1没有在阻塞其他线程,每个线程的锁都在with语句块结束后被释放,但是我们也发现,代码中的一处debug日志:logging.debug("Thread {}: released the lock".format(thread_num))貌似没有起到作用,每个线程都没有输出锁释放了的信息,这是因为线程发生了异常就立即中断了,而且对应的错误信息也没有显示。 为此,我们可能会产生一个忧虑:如果线程因为异常中断了,我们怎么知道是什么问题导致了线程中断。
对于这个问题,我们需要知道,concurrent.futures.ThreadPoolExecutor的实例化对象的submit函数会返回一个Future对象,我们可以对这个对象使用running或exception等函数,来得知对应线程的运行或异常状态等信息。现在让我们改写一下之前的代码,来让线程中发生的异常被正常报告。
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
def test(thread_num):
logging.debug("Thread {}: about to acquire lock".format(thread_num))
with Lock():
logging.debug("Thread {}: got the lock".format(thread_num))
logging.debug("Thread {}: about to release lock".format(thread_num))
raise ValueError("Thread {}: something went wrong...".format(thread_num))
logging.debug("Thread {}: released the lock".format(thread_num))
with ThreadPoolExecutor(max_workers=2) as executor:
for i in range(2):
f = executor.submit(test, i+1)
f_exception = f.exception()
if f_exception:
logging.error("Error: {}".format(i+1, f_exception))
我们在原来代码基础上,获取了submit函数的返回对象,并且使用exception来获取对应线程可能存在的异常信息,然后用logging.error显示出来。在运行了以上代码后,我们会看到类似以下的结果:
21:32:42: Thread 1: about to acquire lock
21:32:42: Thread 1: got the lock
21:32:42: Thread 1: about to release lock
21:32:42: Error: Thread 1: something went wrong...
21:32:42: Thread 2: about to acquire lock
21:32:42: Thread 2: got the lock
21:32:42: Thread 2: about to release lock
21:32:42: Error: Thread 2: something went wrong...
我们如愿看到了每个线程在发生异常时的错误信息,其实除了使用以上的方法来捕捉线程的异常,我们还可以使用装饰器函数。装饰器函数可以用于输出一个函数的各项基本参数,例如函数文档,函数接受参数和函数运行时间等来简化函数代码,或者管理函数的某些信息。现在我们试试写一个捕捉异常可以捕捉异常的装饰器函数:
from functools import wraps
def detect_exception(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
logging.error(e)
return wrapper
然后对以前的代码修改一下,在原来的test函数前加上@detect_exception
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
import logging
from functools import wraps
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
def detect_exception(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
logging.error(e)
return wrapper
@detect_exception
def test(thread_num):
logging.debug("Thread {}: about to acquire lock".format(thread_num))
with Lock():
logging.debug("Thread {}: got the lock".format(thread_num))
logging.debug("Thread {}: about to release lock".format(thread_num))
raise ValueError("Thread {}: Something went wrong...".format(thread_num))
logging.debug("Thread {}: released the lock".format(thread_num))
with ThreadPoolExecutor(max_workers=2) as executor:
for i in range(2):
executor.submit(test, i+1)
我们运行了以上代码就会看到类似以下的结果:
18:27:07: Thread 1: about to acquire the lock 18:27:07: Thread 1: got the lock 18:27:07: Thread 1: about to release the lock 18:27:07: Thread 1: Something went wrong... 18:27:07: Thread 2: about to acquire the lock 18:27:07: Thread 2: got the lock 18:27:07: Thread 2: about to release the lock 18:27:07: Thread 2: Something went wrong...
两个方法都成功输入了线程中的异常信息,接下来让我们继续下一个会导致线程死锁的原因。
6.2 意料之外的死锁原因之一 -- 重复获取同一个锁
我们之前使用的threading.Lock,如果已经获取了一次,在该锁释放之前又再次获取就会导致死锁。可能我们会觉得,这个问题只要对锁使用了release方法或者用with语句就能解决。然而问题并没有这么简单,有时候,我们可能会在函数调用函数过程中重复获取同一个所,让我们举个例子:
import threading
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
class Counter:
def __init__(self):
self._lock = threading.Lock()
self.a = 0
self.b = 0
def countA(self):
logging.debug("Run countA")
with self._lock:
logging.debug("countA: about to get lock")
self.a += 1
logging.debug("countA: about to release lock")
def countB(self):
logging.debug("Run countB")
with self._lock:
logging.debug("countB: about to get lock")
self.countA()
self.b += self.a
logging.debug("countB: about to release lock")
C = Counter()
C.countB()
print("C.a: ", C.a)
print("C.b: ", C.b)
我们运行以上代码会得到类似以下的结果:
11:15:10: Run countB
11:15:10: countB: about to get lock
11:15:10: Run countA
程序就一直卡在了输出Run countA之后,这是因为程序在countB函数中获取了self._lock锁,然后在释放前调用了countA函数,countA函数内中又获取了self._lock锁,导致了死锁。对于这种因为调用函数而发生锁重复获取导致的死锁,在使用了锁的递归函数中问题会更加明显。
这时候我们可以考虑使用threading.RLock,这种锁可以在已经获取之后还未释放之前再多次获取,但是它的释放次数应该和获取次数相同,可以保证线程安全同时也避免因为重复获取锁而导致的死锁。
我们把原来代码里的Lock换成RLock再次运行,就会看到类似以下的结果:
15:24:11: Run countB
15:24:11: countB: about to get lock
15:24:11: Run countA
15:24:11: countA: about to get lock
15:24:11: countA: about to release lock
15:24:11: countB: about to release lock
15:24:11: C.a: 1
15:24:11: C.b: 1
死锁的问题已经被修复,这么看来貌似RLock比Lock方便好用多了,是不是以后只用RLock不用Lock就能解决问题。然而实际上,有时使用RLock虽然不会阻塞线程也可能导致错误的运行结果,这完全取决于我们的代码逻辑,我们应该牢记我们选择使用锁的初衷,并在进行选择锁的时候考虑清楚。
到目前为止,我们多python多线程以及锁的使用已经有了一些了解,然而我们还尚未达到可以对这些知识灵活运用的地步,比如说关于锁,我们已经知道了它可以用来控制某些数据或资源一个时间只能由一个线程来访问或修改,但是这样只能做到让线程一个接一个地做一件事,如果我们要让两个线程轮流交替各自做一件事该怎么实现呢?
7. 生产者与消费者
一个经典问题--生产者与消费者,这个问题就需要我们考虑用两个线程轮流交替做它们要做的事。生产者就是数据源,会不断地产生数据,而消费者持续监听新数据的产生,如果有新数据就存入数据库,生产者在一定时间内产生的数据太多了,如果消费者一次把大量数据直接存数据库可能会给数据库带来很大的负载压力,或者数据库会因为数据量大负载过高导致存储效率下降。
7.1 自定义Pipeline解决问题
为此,生产者与消费者之间,存在着一个传输管道,pipeline,生产者每次只能向管道传入一个数据,然后管道中一旦有数据传入,消费者就会将其存入到数据库。
现在让我们先创建消费者和生产者:
from concurrent.futures import ThreadPoolExecutor
import logging
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format,level=logging.INFO,
datefmt="%H:%M:%S")
logging.getLogger().setLevel(logging.DEBUG)
SENTINEL = object
DB = list()
@detect_exception
def producer(pipeline):
global SENTINEL
for i in range(10):
pipeline.set_data(i, "Producer")
logging.info("Producer: store data -- {}".format(i))
pipelne.set_data(SENTINEL, "Producer")
@detect_exception
def consumer(pipeline):
global SENTINEL
while True:
value = pipeline.get_data("Consumer")
logging.info("Consumer: got data -- {}".format(value))
if value is SENTINEL:
break
DB.append(value)
p = Pipeline()
with ThreadPoolExecutor(max_workers=2) as executor:
executor.submit(producer, p)
executor.submit(consumer, p)
在以上代码中,我们编写了
- 生产者函数:一共有十个数通过pipeline进行存储
- 消费者函数: 持续监听pipeline是否有新数据,然后存入数据库
我们用一个list作为伪数据库,并且我们创建了一个SENTINEL对象,当生产者把所有数据存完后就把这个SENTINEL存入pipeline,以告诉消费者所有数据都处理完毕。此外我们还在每个函数前装饰了之前编写的异常捕捉函数,这样在线程因异常中断我们就能及时发现了。
在上面代码中,我们使用了还未创建的类Pipeline,我们预设了Pipeline具有两个方法,set_data存储数据,get_data获取数据,在还未明确这两个方法的实现方式,Pipeline总体上来说应该是这样:
Pipeline:
def __init__(self):
self.value = None
def set_data(self):
pass
def get_data(self):
pass
现在的问题是我们改如何填补代码,让生产者存入一个数据后消费者就获取并存入数据库,这样来回交替工作?我们之前知道锁可以用于阻塞线程,获取锁的线程可以继续运行而其他线程就会被阻塞。现在我们有两个线程,生产者和消费者,在还没有锁的情况下,两个线程都会被运行,但是我们想要的是,先运行生产者存入一个数据,然后在运行消费者,所以消费者一开始应该是被阻塞的。一开始我们可能会想用一个锁,但这是不行的,如果只有一个锁,两个线程谁先获得锁是不一定的,所以...我们为什么不用两个锁,一个生产者锁,一个消费者锁。一开始在消费者要取数据前,先获取消费者锁,这样消费者线程一开始就会被阻塞。接下来我们想生产者和消费者轮流交替运行,那么如果我们在生产者存入数据时获取生产者锁,执行完后释放消费者锁,这样生产者执行一次之后就会被阻塞,然后消费者可以运行,消费者获取数据时,获取消费者锁,执行完后再释放生产者锁,这样是不是就可以了呢,让我们试试按以上想法,完成Pipeline类:
import threading
class Pipeline:
def __init__(self):
self.value = None
self._producer_lock = threading.Lock()
self._consumer_lock = threading.Lock()
self._consumer_lock.acquire()
def set_data(self, value, name):
logging.debug("{}: about to lock".format(name))
self._producer_lock.acquire()
self.value = value
logging.debug("{}: about to release lock".format(name))
self._consumer_lock.release()
def get_data(self, name):
logging.debug("{}: about to get lock".format(name))
self._consumer_lock.acquire()
value = self.value
logging.debug("{}: about to release lock".format(name))
self._producer_lock.release()
return value
我们将以上代码合并,运行后会看到类似以下的结果
16:52:21: Producer: about to producer lock
16:52:21: Producer: about to release consumer lock
16:52:21: Producer: store data -- 0
16:52:21: Producer: about to producer lock
16:52:21: Consumer: about to get consumer lock
16:52:21: Consumer: about to release producer lock
16:52:21: Consumer: got data -- 0
16:52:21: Consumer: about to get consumer lock
16:52:21: Producer: about to release consumer lock
16:52:21: Producer: store data -- 1
16:52:21: Producer: about to producer lock
16:52:21: Consumer: about to release producer lock
16:52:21: Consumer: got data -- 1
...
16:52:21: Consumer: about to release producer lock
16:52:21: Producer: about to producer lock
16:52:21: Consumer: got data -- 9
16:52:21: Producer: about to release consumer lock
16:52:21: Consumer: about to get consumer lock
16:52:21: Consumer: about to release producer lock
16:52:21: Consumer: got data -- <class 'object'>
16:52:21: Data in fake database: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
根据日志信息,可以看到我们成功实现了生产者消费者这个问题的需求,通过交替获取和释放锁,来做到让两个线程轮流交替做各自的事。
我们基本上解决了生产者和消费者问题,但是我们只能每次存入一个数据,这样看起来效率貌似不是很高,有没有更好的办法,实现生产者和消费者的需求?
7.2 使用queue和threading.event
其实是有的,Python的一个标准库--queue,我们完全可以用它的Queue类实现的消息队列替代我们之前编写的Pipeline,queue实现的消息队列的容量可以自定,默认情况下是按系统的内存限制而定的,并且它内部存在着锁机制可以保证线程安全。所以我们要用queue.Queue来解决我们之前消息队列容量太小的问题,并且我们还将会用到Python标准库threading.Event。
之前我们是创建了一个SENTINEL对象,在producer生产完所有数据后把SENTINEL对象传入Pipeline来告诉Consumer:已经没有数据了。但在实际情况中,我们是通过网络通信的方式获取数据,这样的话要使用SENTINEL这种方式并不是合适,而使用threading.Event就可以很方便地与线程间交互。线程里可以用is_set函数获取Event对象目前的状态,然后在线程外我们也可以用.set来设定Event对象。
现在让我们用queue.Queue和threading.event来修改之前的代码:
import threading
from concurrent.futures import ThreadPoolExecutor
import logging
import random
import time
import queue
format = "%(asctime)s: %(message)s"
logging.basicConfig(format=format, level=logging.INFO,
datefmt="%H:%M:%S")
DB = list()
def producer(pipeline, event):
while not event.is_set():
value = random.randint(1, 101)
logging.info("Producer: got value -- {}".format(value))
pipeline.put(value)
logging.info("Producer: received event, existing...")
def consumer(pipeline, event):
while not event.is_set or not pipeline.empty():
value = pipeline.get()
DB.append(value)
logging.info("Consumer: stored value -- {} into DB(queue's size is {})".format(value, pipeline.size()))
logging.info("Consumer: received event, existing...")
P = queue.Queue()
E = threading.Event()
with ThreadPoolExecutor(max_workers=2) as executor:
executor.submit(producer, P, E)
executor.submit(consumer, P, E)
time.sleep(0.1)
E.set()
logging.info("Main thread : DB -- {}".format(DB))
运行以上代码后,我们会看到类似以下的结果:
15:05:25: Producer: got value -- 53
15:05:25: Producer: got value -- 36
15:05:25: Consumer: stored value -- 53 into DB(queue's size is 0)
15:05:25: Consumer: stored value -- 36 into DB(queue's size is 0)
15:05:25: Producer: got value -- 38
15:05:25: Producer: got value -- 64
15:05:25: Producer: got value -- 67
15:05:25: Producer: got value -- 65
15:05:25: Consumer: stored value -- 38 into DB(queue's size is 0)
15:05:25: Producer: got value -- 70
...
15:05:25: Consumer: stored value -- 63 into DB(queue's size is 2)
15:05:25: Consumer: stored value -- 96 into DB(queue's size is 1)
15:05:25: Consumer: stored value -- 80 into DB(queue's size is 0)
15:05:25: Consumer: received event, existing...
15:05:25: Producer: got value -- 67
15:05:25: Producer: received event, existing...
15:05:25: Main thread : DB -- [53, 36, 38, 64, 67, 65, 70, 12, 40, 49, 46, 75, 4, 30, 98, 24, 42, 12, 99, 49, 56, 63, 96, 80]
可以看到,生产者和消费者线程被系统交替执行,在后续的主线程中我们sleep了0.1秒并设定event,当系统运行到这里,其他线程运行get_set函数就会得知事件被触发,需要立刻停止工作。到此我们完成了生产者和消费者问题的需求,并且我们使用queue.Queue减少了之前开发的代码,提高了消息队列的容量和数据处理效率,并且用threading.Event及时停止线程。
8. 进一步了解线程
到目前为止,我们已经了解了python多线程的许多内容,如python的threading模块,concurrent.futures.ThreadPoolExecutor类,什么是竞态争用,解决竞态争用的办法,生产者与消费者问题以及解决的方案。在这篇文章收尾之前,我们再了解一下python的threading模块提供的一些类,这些类可以在我们实际工作中提供帮助。
- Semaphore
threading.Semaphore类是一个内部计数器,可以设定初始的value(默认为1), 使用acquire会递减1,使用release会递增1。这些递增或递减操作都是原子性的,即计数过程中系统不会中断去做其他事。如果值为0了,acquire方法会阻塞直到有其他线程调用了release方法。
threading.Semaphore适用于一些承载力或者容量有限的资源。例如说我们想限制我们后台服务的连接请求数,threading.Semaphore会是一个好的工具。
- Timer
threading.Timer可以设定在多少秒后新起一个线程来执行某个函数,当我们设定了Timer的预计执行事件和目标函数,再使用start来开始计时,Timer执行函数的时间可能和我们预计设定的时间略微有些出入。我们也可以用cancel来提前取消Timer,如果Timer已经触发,cancel函数就什么也不会做,但也不报错。当我们想设定程序某些函数的执行日程,我们就可以考虑使用threading.Timer。
- Barrier
threading.Barrier可以用于保持一定数量的线程同步。当我们要使用Barrier的时候,需要先设定要同步的线程数,然后执行了.wait方法的线程会去等还没执行.wait的线程,直到执行到.wait的线程达到了之前设定的数量。threading.Barrier适用于当我们想让一些线程执行到某一步后开始同步的情况下。
最后收尾
到这里这篇文章就要结束了,到目前为止,我们学习了共八个模块的内容:
- python多线程的本质: python存在全局解释锁,所以python的多线程只能是在线程来回切换执行
- 实现python多线程的工具: threading
- 实现python多线程管理工具:concurrent.futures.ThreadPoolExecutor
- 什么是竞态争用: 多个线程同时处理同一资源可能会产生错误的结果
- 解决竞态争用的工具: threading.Lock
- 什么是死锁
- 导致死锁的两种可能情况: 没有及时释放锁或者重复获取同一个普通锁
- 生产者与消费者模型以及实现的方案
文章的内容大部分来自于,An Intro to Threading in Python,感兴趣的小伙伴可以去看看。