目录
前言
程序中使用多线程技术是充分利用CPU的好办法,利用好的话可以大大加速程序的任务处理效率,但是一些细节不注意的话,就会造成一些隐藏的问题。
场景
我们现在在服务器上跑着一个程序,这个程序是用来处理新用户注册的,由于我们的网站很受欢迎,用户注册量超高速增长,会有很多用户在同一时间点上注册。为了减少用户等待时间,我们在注册程序里使用多线程技术来同时服务多个用户的注册请求。
每个用户注册的时候都会得到一个用户 id,我们的注册程序要保证每个用户的id是独一无二的,并且每个新用户的id要向上依次增长。
写一个发号器
由于涉及到与数据库同步以及为了提高代码的封装度,我们写一个发号器类(class),这个发号器要保证无论在何时何地调用其 get_new_id() 方法都能生成一个最新的id,并且将新id值同步到数据库。
由于是演示,就先不连接真的数据库了,我们自己模拟一下数据库,包括读/写操作,由于真实场景里数据库读和写都需要有一定时间的,所以这里读/写操作都用sleep()函数模拟了一定的延时。
import time
class ID_Generator:
"""发号器,希望生成的id有序自增,并且实时更新数据库"""
def __init__(self):
# 假装下边的database是一个数据库
self.database = {'data': 0}
def read_from_database(self):
"""从数据库中读数据"""
time.sleep(0.01)
return self.database['data']
def write_to_database(self, value):
"""向数据库中写数据"""
time.sleep(0.01)
self.database['data'] = value
我们写一个发号函数,先从数据库中读取之前的id,用之前的id值加一算出一个新的id,并且把最新id同步到数据库,最后再返回新id。这样我们就模拟了一个发号过程。
import threading
import time
class ID_Generator:
def __init__(self):
...
def read_from_database(self):
...
def write_to_database(self, value):
...
def get_new_id(self):
"""
从数据库中读取之前的id,用之前的id值加一算出一个新的id,
并且把最新id同步到数据库,最后再返回新id。
"""
current_id = self.read_from_database() # 读取旧id
new_id = current_id + 1 # 算出新id
self.write_to_database(new_id) # 将新的id值同步到数据库
return new_id # 返回这个最新id
看起来上边的操作很合情理,取出旧id加一得到新id最后再把新id写回去,但是,如果在单线程程序下上边的发号过程是没什么问题的,一旦到了多线程程序中就会出现问题。
下面,我们模拟一下多个线程调用一个用户注册函数,这个用户注册函数接受一个发号器对象实例用来获得最新的id。
import time
import threading
class ID_Generator:
...
def user_register(id_generator):
"""模拟用户注册"""
new_id = id_generator.get_new_id() # 从公共发号器中获取一个最新的id
#
# 注册有关的其他操作这里省略……
#
print(f"{threading.currentThread().getName()}: User {new_id} has registered")
id_generator = ID_Generator() # 建立一个公共发号器对象
# 同时运行若干个用户注册线程,并且给注册函数都传入公共发号器对象以便其取号
t1 = threading.Thread(name="t1", target=user_register, args=(id_generator,))
t2 = threading.Thread(name="t2", target=user_register, args=(id_generator,))
t3 = threading.Thread(name="t3", target=user_register, args=(id_generator,))
t4 = threading.Thread(name="t4", target=user_register, args=(id_generator,))
t5 = threading.Thread(name="t5", target=user_register, args=(id_generator,))
t1.start()
t2.start()
t3.start()
t4.start()
t5.start()
如果这是一个单线程程序,也就是说5个user_register()函数依次运行,那么我们会很完美地得到从1到5依次递增的5个id。
可是这是多线程程序,经过运行,我们的结果是这样的:
什么!?我们得到了5个id相同的用户!这可不太妙。
仔细想想,我们的5个用户注册函数是同时运行起来的,因此有可能同时调用了公共发号器的get_new_id()函数 (某个线程的get_new_id还没执行完,就有另一个线程也开始调用get_new_id了)。
因此就会出现这样的场景,线程1内的get_new_id()函数刚从数据库中读取了旧id,正在计算新id,还没有把新的id写入数据库,这时线程2的get_new_id()函数也读取了数据库获得了同样的旧id,于是,它们两个都计算出了相同的新id,并且最后都把这个相同的id写回了数据库。
在这个过程中,我们本应该产生2个不同的新id的,但是事实上我们却产生了2个相同的id !这对于你的写的整个系统来说就很麻烦了。
解决办法
解决问题的关键在于,我们某个线程中的get_new_id()函数在执行【读取并写回数据库】这个操作的时候不应当有其他的线程来读写数据库,我们应当使在同一时间段内只能有一个线程来执行它的读写数据库的代码,在其他线程正在读写数据库时,自己作为一个线程应该等待正在读写数据库的线程读写完毕,然后才轮到自己。
多线程中有一个线程锁的概念,如果某个线程在某一时刻向系统申请了线程锁,在其释放线程锁之前,其他线程(拥有相同的代码)在申请线程锁的位置会被暂停,直到之前的线程释放掉线程锁,自己才有可能申请到线程锁得以执行下去,也就是说,一个程序中的线程锁只有一个,需多个线程来抢,谁抢到了线程锁就可以运行线程锁申请与释放之间的代码,其他没抢到的线程只能运行线程锁申请与释放之外的代码。
我们把数据库读写的代码放到线程锁申请与释放之间,这样就能保证数据库读写代码在同一时刻只能由一个线程来执行了。
import threading
import time
class ID_Generator:
...
def __init__(self):
...
self.lock = threading.Lock() # 建立一个线程锁对象
def read_from_database(self):
...
def write_to_database(self, value):
...
def get_new_id(self):
self.lock.acquire() # 获取线程锁
current_id = self.read_from_database()
new_id = current_id + 1
self.write_to_database(new_id)
self.lock.release() # 释放获取到的线程锁
return new_id
完美运行!
线程锁执行流程验证
我们来写一段代码验证一下2个线程在分别获取线程锁时的执行情况。
import threading
import time
global_thread_lock = threading.Lock()
def task_1():
for i in range(3):
print("线程1正在执行非线程锁之间的代码")
time.sleep(0.1)
print("线程1开始申请线程锁")
global_thread_lock.acquire()
print("线程1申请到了线程锁")
for i in range(3):
print("线程1正在执行线程锁之间的代码")
time.sleep(1)
global_thread_lock.release()
print("线程1释放了线程锁")
def task_2():
for i in range(5):
print("线程2正在执行非线程锁之间的代码")
time.sleep(0.1)
print("线程2开始申请线程锁")
global_thread_lock.acquire()
print("线程2申请到了线程锁")
for i in range(3):
print("线程2正在执行线程锁之间的代码")
time.sleep(1)
global_thread_lock.release()
print("线程2释放了线程锁")
t1 = threading.Thread(target=task_1)
t2 = threading.Thread(target=task_2)
t1.start()
t2.start()
我们写了2个线程,它们分别都有非线程锁之间的代码和处于线程锁之间的代码。线程1先申请线程锁,申请到了后开始执行线程1的线程锁间的代码,过一段时间后线程2也开始申请线程锁,此时线程2会暂停等待线程锁释放,直到线程1释放了线程锁,线程2获得线程锁得以执行其线程锁见的代码。
我们来看运行结果,应该是很直观了。
完结!