zoukankan      html  css  js  c++  java
  • 通俗易懂:说说 Python 里的线程安全、原子操作

    首发于微信公众号:Python编程时光

    在线博客地址:http://python.iswbm.com/en/latest/c01/c01_42.html


    在并发编程时,如果多个线程访问同一资源,我们需要保证访问的时候不会产生冲突,数据修改不会发生错误,这就是我们常说的 线程安全

    那什么情况下,访问数据时是安全的?什么情况下,访问数据是不安全的?如何知道你的代码是否线程安全?要如何访问数据才能保证数据的安全?

    本篇文章会一一回答你的问题。

    1. 线程不安全是怎样的?

    要搞清楚什么是线程安全,就要先了解线程不安全是什么样的。

    比如下面这段代码,开启两个线程,对全局变量 number 各自增 10万次,每次自增 1。

    from threading import Thread, Lock
    
    number = 0
    
    def target():
        global number
        for _ in range(1000000):
            number += 1
    
    thread_01 = Thread(target=target)
    thread_02 = Thread(target=target)
    thread_01.start()
    thread_02.start()
    
    thread_01.join()
    thread_02.join()
    
    print(number)
    

    正常我们的预期输出结果,一个线程自增100万,两个线程就自增 200 万嘛,输出肯定为 2000000 。

    可事实却并不是你想的那样,不管你运行多少次,每次输出的结果都会不一样,而这些输出结果都有一个特点是,都小于 200 万。

    以下是执行三次的结果

    1459782
    1379891
    1432921
    

    这种现象就是线程不安全,究其根因,其实是我们的操作 number += 1 ,不是原子操作,才会导致的线程不安全。

    2. 什么是原子操作?

    原子操作(atomic operation),指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会切换到其他线程。

    它有点类似数据库中的 事务

    在 Python 的官方文档上,列出了一些常见原子操作

    L.append(x)
    L1.extend(L2)
    x = L[i]
    x = L.pop()
    L1[i:j] = L2
    L.sort()
    x = y
    x.field = y
    D[x] = y
    D1.update(D2)
    D.keys()
    

    而下面这些就不是原子操作

    i = i+1
    L.append(L[-1])
    L[i] = L[j]
    D[x] = D[x] + 1
    

    像上面的我使用自增操作 number += 1,其实等价于 number = number + 1,可以看到这种可以拆分成多个步骤(先读取相加再赋值),并不属于原子操作。

    这样就导致多个线程同时读取时,有可能读取到同一个 number 值,读取两次,却只加了一次,最终导致自增的次数小于预期。

    当我们还是无法确定我们的代码是否具有原子性的时候,可以尝试通过 dis 模块里的 dis 函数来查看

    当我们执行这段代码时,可以看到 number += 1 这一行代码,由两条字节码实现。

    • BINARY_ADD :将两个值相加
    • STORE_GLOBAL: 将相加后的值重新赋值

    每一条字节码指令都是一个整体,无法分割,他实现的效果也就是我们所说的原子操作。

    当一行代码被分成多条字节码指令的时候,就代表在线程线程切换时,有可能只执行了一条字节码指令,此时若这行代码里有被多个线程共享的变量或资源时,并且拆分的多条指令里有对于这个共享变量的写操作,就会发生数据的冲突,导致数据的不准确。

    为了对比,我们从上面列表的原子操作拿一个出来也来试试,是不是真如官网所说的原子操作。

    这里我拿字典的 update 操作举例,代码和执行过程如下图

    从截图里可以看到,info.update(new) 虽然也分为好几个操作

    • LOAD_GLOBAL:加载全局变量
    • LOAD_ATTR: 加载属性,获取 update 方法
    • LOAD_FAST:加载 new 变量
    • CALL_FUNCTION:调用函数
    • POP_TOP:执行更新操作

    但我们要知道真正会引导数据冲突的,其实不是读操作,而是写操作。

    上面这么多字节码指令,写操作都只有一个(POP_TOP),因此字典的 update 方法是原子操作。

    3. 实现人工原子操作

    在多线程下,我们并不能保证我们的代码都具有原子性,因此如何让我们的代码变得具有 “原子性” ,就是一件很重要的事。

    方法也很简单,就是当你在访问一个多线程间共享的资源时,加锁可以实现类似原子操作的效果,一个代码要嘛不执行,执行了的话就要执行完毕,才能接受线程的调度。

    因此,我们使用加锁的方法,对例子一进行一些修改,使其具备原子性。

    from threading import Thread, Lock
    
    
    number = 0
    lock = Lock()
    
    
    def target():
        global number
        for _ in range(1000000):
            with lock:
                number += 1
    
    thread_01 = Thread(target=target)
    thread_02 = Thread(target=target)
    thread_01.start()
    thread_02.start()
    
    thread_01.join()
    thread_02.join()
    
    print(number)
    

    此时,不管你执行多少遍,输出都是 2000000.

    4. 为什么 Queue 是线程安全的?

    Python 的 threading 模块里的消息通信机制主要有如下三种:

    1. Event
    2. Condition
    3. Queue

    使用最多的是 Queue,而我们都知道它是线程安全的。当我们对它进行写入和提取的操作不会被中断而导致错误,这也是我们在使用队列时,不需要额外加锁的原因。

    他是如何做到的呢?

    其根本原因就是 Queue 实现了锁原语,因此他能像第三节那样实现人工原子操作。

    原语指由若干个机器指令构成的完成某种特定功能的一段程序,具有不可分割性;即原语的执行必须是连续的,在执行过程中不允许被中断。

    关注公众号,获取最新干货!

  • 相关阅读:
    安全编码1
    VPP tips
    VPP概述汇总
    C语言安全编码摘录
    TCP-proxy
    Scipy Lecture Notes学习笔记(一)Getting started with Python for science 1.4. Matplotlib: plotting
    Scipy Lecture Notes学习笔记(一)Getting started with Python for science 1.3. NumPy: creating and manipulating numerical data
    Scipy Lecture Notes学习笔记(一)Getting started with Python for science 1.2. The Python language
    Scipy Lecture Notes学习笔记(一)Getting started with Python for science 1.1. Python scientific computing ecosystem
    25马5跑道,求最快的五匹马的需要比赛的次数
  • 原文地址:https://www.cnblogs.com/wongbingming/p/12892927.html
Copyright © 2011-2022 走看看