Skip to content

无锁不安全

先来看一个案例

py
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 )自带的一个库,可以用来分析字节码

如果多个线程同时对同一个变量操作,会出现资源竞争问题,从而数据结果会不正确

在考虑竞争条件时,要记住两件事:

  1. 即使是像操作一样的操作也 x += 1 需要多处理器。这些步骤中的每一步都是对处理器的单独指令。
  2. 操作系统可以换哪个线程运行 在任何时间 。在任何这些小指令之后,可以换出一个线程。这意味着,一个线程可以被置于睡眠状态,让在另一个线程运行中一个 Python 语句。

让我们详细看看下面这个函数,它接受一个参数并递增它:

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

程序执行的过程

  1. 执行 LOAD_FAST 数据值 x

  2. 执行a LOAD_CONST 1

  3. INPLACE_ADD 这些值添加到一起。

我们因特定原因停在这里。这是强制线程切换的 .update() 上述要点 time.sleep() 。完全有可能的是,每隔一段时间,操作系统就会在该确切点切换线程,即使没有sleep(),但是sleep()每次调用都会使它发生。

有许多方法可以避免或解决竞争条件。你不会在这里看到所有这些,但有一些经常使用。让我们开始学习 Lock

要解决上面的竞争条件,您需要找到一种方法,一次只允许一个线程进入代码的读 - 修改 - 写部分。最常见的方法是 Lock 在 Python 中调用。

Lock 是一个像通行证一样的物体。一次只能有一个线程 Lock。任何其他想要通过 Lock 的线程,必须等到 Lock 的所有者释放它。

执行此操作的基本功能是 .acquire().release() 。如果锁已经被保持,则调用线程将一直等到它被释放。这里有一个重点。如果一个线程获得锁定但从未将其返回,则程序将被卡住。就会造成死锁。

threading 模块中定义了 Lock 类,可以方便的处理锁定:

python
import threading

# 创建锁
lock = threading.Lock()

# 锁定
lock.acquire()

... # 需要加锁的代码

# 释放
lock.release()

Python Lock 也将作为上下文管理器运行,因此您可以在 with 语句中使用它,并且当 with 块因任何原因退出时它会自动释放。

python
import threading

lock = threading.Lock()
with lock:
    ... # 需要加锁的代码

让我们来看看 Lock 。在之前的函数中使用锁:

python
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 复制,更新,休眠,然后将值写回。

clip_image001

注意:

  • 如果这个锁之前是没有上锁的,那么 acquire 不会堵塞
  • 如果在调用 acquire 对这个锁上锁之前 它已经被 其他线程上了锁,那么此时 acquire 会堵塞,直到这个锁被解锁为止

对敏感代码加锁

py
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() 。运行此代码时,您认为会发生什么:

python
import threading

l = threading.Lock()
print("第一次获取锁")
l.acquire()
print("第二次获取锁")
l.acquire()

当程序 l.acquire() 第二次调用时,它会挂起等待 Lock 释放。在此示例中,您可以通过删除第二个调用来修复死锁,但死锁通常发生在两个微妙的事情之一:

  1. Lock未正确发布的实现错误
  2. 一个设计问题,其中实用程序函数需要由可能已经或可能没有的函数调用 Lock

第一种情况有时会发生,但使用 Lock 上下文管理器会大大减少频率。建议尽可能编写代码以使用上下文管理器,因为它们有助于避免异常跳过.release()调用的情况。

在某些语言中,设计问题可能有点棘手。值得庆幸的是,Python线程有一个名为的第二个对象,RLock 专门针对这种情况而设计。它允许一个线程 .acquire()RLock 多次调用之前 .release() 。该线程仍然需要调用 .release() 它调用的相同次数 .acquire() ,但无论如何它应该这样做。

Lock 并且 RLock 是用于线程编程以防止竞争条件的两个基本工具。还有一些其他方式以不同的方式工作。在你看之前,让我们转向一个稍微不同的问题域。

此时已经进入到了死锁状态

总结

锁的好处:

  • 确保了某段关键代码只能由一个线程从头到尾完整地执行

锁的坏处:

  • 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了
  • 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁

案例:对敏感数据加锁

请找出下面案例中的敏感操作,并且对其加锁,确保数据没有问题。

python
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

案例:有返回值的任务

请将下面案例的内容转化为多线程执行,并且将返回结果打印

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