无锁不安全
先来看一个案例
import threading
import random
import time
with open('number.txt', mode='w', encoding='utf-8') as file:
file.write('0')
def add1():
for i in range(10):
time.sleep(random.random() / 10)
txt_num = open('number.txt', mode='r', encoding='utf-8').read()
print(f'add1 {i} 读取数字 ->{txt_num}')
ret = int(txt_num) + 1
print(f'add1 {i} 修改数字 ->{txt_num}')
open('number.txt', mode='w', encoding='utf-8').write(str(ret))
print(f'add1 {i} 写入数字 ->{txt_num}')
def add2():
for i in range(10):
time.sleep(random.random() / 10)
txt_num = open('number.txt', mode='r', encoding='utf-8').read()
print(f'add2 {i} 读取数字 ->{txt_num}')
ret = int(txt_num) + 1
print(f'add2 {i} 改变数字 ->{txt_num}')
open('number.txt', mode='w', encoding='utf-8').write(str(ret))
print(f'add2 {i} 写入数字 ->{txt_num}')
if __name__ == '__main__':
thread1 = threading.Thread(target=add1)
thread2 = threading.Thread(target=add2)
thread1.start()
thread2.start()
代码的逻辑,理论上可以将数字添加到 20,但是实际运行之后,却得不到这个数字。
不安全的原因
dis 库是 python (默认的 CPython
)自带的一个库,可以用来分析字节码
如果多个线程同时对同一个变量操作,会出现资源竞争问题,从而数据结果会不正确
在考虑竞争条件时,要记住两件事:
- 即使是像操作一样的操作也
x += 1
需要多处理器。这些步骤中的每一步都是对处理器的单独指令。 - 操作系统可以换哪个线程运行 在任何时间 。在任何这些小指令之后,可以换出一个线程。这意味着,一个线程可以被置于睡眠状态,让在另一个线程运行中一个 Python 语句。
让我们详细看看下面这个函数,它接受一个参数并递增它:
# 没有锁
def add1(a):
a += 1
"""add
1. load a a = 0
2. load 1 1
3. + 1
4. 赋值给a a=1
"""
import dis
dis.dis(add1)
运行结果
37 0 LOAD_FAST 0 (a)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_FAST 0 (a)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
程序执行的过程
执行
LOAD_FAST
数据值x
执行a
LOAD_CONST 1
将
INPLACE_ADD
这些值添加到一起。
我们因特定原因停在这里。这是强制线程切换的 .update()
上述要点 time.sleep()
。完全有可能的是,每隔一段时间,操作系统就会在该确切点切换线程,即使没有sleep()
,但是sleep()
每次调用都会使它发生。
锁
有许多方法可以避免或解决竞争条件。你不会在这里看到所有这些,但有一些经常使用。让我们开始学习 Lock
。
要解决上面的竞争条件,您需要找到一种方法,一次只允许一个线程进入代码的读 - 修改 - 写部分。最常见的方法是 Lock
在 Python 中调用。
Lock
是一个像通行证一样的物体。一次只能有一个线程 Lock
。任何其他想要通过 Lock
的线程,必须等到 Lock
的所有者释放它。
执行此操作的基本功能是 .acquire()
和 .release()
。如果锁已经被保持,则调用线程将一直等到它被释放。这里有一个重点。如果一个线程获得锁定但从未将其返回,则程序将被卡住。就会造成死锁。
threading 模块中定义了 Lock 类,可以方便的处理锁定:
import threading
# 创建锁
lock = threading.Lock()
# 锁定
lock.acquire()
... # 需要加锁的代码
# 释放
lock.release()
Python Lock
也将作为上下文管理器运行,因此您可以在 with
语句中使用它,并且当 with
块因任何原因退出时它会自动释放。
import threading
lock = threading.Lock()
with lock:
... # 需要加锁的代码
让我们来看看 Lock
。在之前的函数中使用锁:
import time
import random
import threading
lock = threading.Lock()
def add1():
for i in range(10):
time.sleep(random.random() / 10)
lock.acquire() # 获取一把锁
txt_num = open('number.txt', mode='r', encoding='utf-8').read()
print(f'add1 {i} 读取数字 ->{txt_num}')
ret = int(txt_num) + 1
print(f'add1 {i} 修改数字 ->{txt_num}')
open('number.txt', mode='w', encoding='utf-8').write(str(ret))
lock.release() # 释放锁
print(f'add1 {i} 写入数字 ->{txt_num}')
_acquire
进行上锁,并由 .release
语句锁定和释放。
值得注意的是,运行此函数的线程将保持该 Lock
状态,直到完全更新数据为止。在这种情况下,这意味着它将保留 Lock
复制,更新,休眠,然后将值写回。
注意:
- 如果这个锁之前是没有上锁的,那么 acquire 不会堵塞
- 如果在调用 acquire 对这个锁上锁之前 它已经被 其他线程上了锁,那么此时 acquire 会堵塞,直到这个锁被解锁为止
对敏感代码加锁
import threading
import random
import time
with open('number.txt', mode='w', encoding='utf-8') as file:
file.write('0')
lock = threading.Lock()
def add1():
for i in range(10):
time.sleep(random.random() / 10)
lock.acquire() # 获取一把锁
txt_num = open('number.txt', mode='r', encoding='utf-8').read()
print(f'add1 {i} read num ->{txt_num}')
ret = int(txt_num) + 1
print(f'add1 {i} change num ->{txt_num}')
open('number.txt', mode='w', encoding='utf-8').write(str(ret))
lock.release() # 释放锁
print(f'add1 {i} write num ->{txt_num}')
def add2():
for i in range(10):
time.sleep(random.random() / 10)
lock.acquire() # 获取一把锁
txt_num = open('number.txt', mode='r', encoding='utf-8').read()
print(f'add2 {i} read num ->{txt_num}')
ret = int(txt_num) + 1
print(f'add2 {i} change num ->{txt_num}')
open('number.txt', mode='w', encoding='utf-8').write(str(ret))
lock.release() # 释放锁
print(f'add2 {i} write num ->{txt_num}')
if __name__ == '__main__':
thread1 = threading.Thread(target=add1)
thread2 = threading.Thread(target=add2)
thread1.start()
thread2.start()
"""
被加锁的内容会变成一个整体,在执行完之前不会释放
加锁可以保证敏感数据不错乱
如果忘记释放锁了就会造成死锁
"""
死锁
在继续之前,您应该在使用时查看常见问题 Lock
。如您所见,如果 Lock
已经获取,则第二次调用 .acquire()
将等待持有 Lock
调用的线程 .release()
。运行此代码时,您认为会发生什么:
import threading
l = threading.Lock()
print("第一次获取锁")
l.acquire()
print("第二次获取锁")
l.acquire()
当程序 l.acquire()
第二次调用时,它会挂起等待 Lock
释放。在此示例中,您可以通过删除第二个调用来修复死锁,但死锁通常发生在两个微妙的事情之一:
Lock
未正确发布的实现错误- 一个设计问题,其中实用程序函数需要由可能已经或可能没有的函数调用
Lock
第一种情况有时会发生,但使用 Lock
上下文管理器会大大减少频率。建议尽可能编写代码以使用上下文管理器,因为它们有助于避免异常跳过.release()
调用的情况。
在某些语言中,设计问题可能有点棘手。值得庆幸的是,Python线程有一个名为的第二个对象,RLock
专门针对这种情况而设计。它允许一个线程 .acquire()
的 RLock
多次调用之前 .release()
。该线程仍然需要调用 .release()
它调用的相同次数 .acquire()
,但无论如何它应该这样做。
Lock
并且 RLock
是用于线程编程以防止竞争条件的两个基本工具。还有一些其他方式以不同的方式工作。在你看之前,让我们转向一个稍微不同的问题域。
此时已经进入到了死锁状态
总结
锁的好处:
- 确保了某段关键代码只能由一个线程从头到尾完整地执行
锁的坏处:
- 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了
- 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁
案例:对敏感数据加锁
请找出下面案例中的敏感操作,并且对其加锁,确保数据没有问题。
import time
import threading
import random
urls = [
f'http://www.baidu.com?page={page}' for page in range(100)
]
lock = threading.Lock()
def download(url, no, name=None):
time.sleep(random.random())
with open('demo.txt', mode='a', encoding='utf-8') as f:
f.write(f'no:{no} name:{name} url:{url}\n')
return None
start_time = time.time()
no = 1
for url in urls:
# download(url, name='百度')
download_thread = threading.Thread(target=download, args=(url, no), kwargs={'name': 'baidu'})
download_thread.start()
no += 1
案例:有返回值的任务
请将下面案例的内容转化为多线程执行,并且将返回结果打印
import threading
import requests
def get_html(url):
response = requests.get(url)
return response.text
def save_html(html, name):
path = '网页'
file_path = path + '\\' + name
with open(file_path, mode='w', encoding='utf-8') as f:
f.write(html)
return file_path
url = 'http://www.baidu.com'
name = 'baidu.html'
html = get_html(url)
file_path = save_html(html, name)
print('保存的文件路径为:', file_path)
url = 'http://www.soso.com'
name = 'soso.html'
html = get_html(url)
file_path = save_html(html, name)
print('保存的文件路径为:', file_path)