Python中的线程锁

Python·语法 2019-02-14 3926 字 11 浏览 点赞

前言

尽管Python中的线程有些鸡肋,但在IO操作中,提速显然。然而线程存在一个缺点,你可能不得不费点心力去关注线程同步的问题。这时我们需要用到线程锁。

为什么需要线程锁

这里有如下一段代码:

import threading

def increase():
    global num
    for i in range(1000000):
        num += 1

def decrease():
    global num
    for i in range(1000000):
        num -= 1

if __name__ == "__main__":
    num = 0

    lock = threading.Lock()
    inc = threading.Thread(target=increase)
    dec = threading.Thread(target=decrease)

    inc.start()
    dec.start()
    inc.join()
    dec.join()
    print(num)

函数increase()总是负责对num加1,函数decrease()总是负责对num减1。然而,上述代码的运行结果总不为0,甚至多次运行的结果也总不一致。

为了探究num不为0的原因,我们可以深入到Python编译后的字节码中去:

def increase():
    global num
    num += 1

if __name__ == "__main__":
    import dis
    num = 0
    print(dis.dis(increase))

# 输出(以下输出内容为Python编译后的字节码):
30           0 LOAD_GLOBAL              0 (num)
              2 LOAD_CONST               1 (1)
              4 INPLACE_ADD
              6 STORE_GLOBAL             0 (num)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
None

可以看到运行num += 1时,Python解释器的执行步骤为:加载全局变量num(LOAD_GLOBAL),加载常量1(LOAD_CONST),相加并且赋值给num(INPLACE_ADD),存储全局变量(STORE_GLOBAL)…… 尽管Python因GIL锁缘故,总是只有一个线程在运行,但多个线程会交叉运行。因此它们会先后加载变量num,然后再去先后存储全局变量num。

细节是这样的:当num = 10时,increase()decrease()先后加载了num,此时它们拿到的num都是100;经过increase()处理后num_local = 101,经过decrease()处理后num_local = 99(num_local表示执行STORE_GLOBA之前的num);假设increase()先执行存储操作,那么执行之后全局的num = 101,接着decrease()执行了存储操作,全局num = 99;最终num等于99。

为了避免此问题发生,我们需要线程锁。

Lock

实例一个线程锁:

lock = threading.Lock()

对象lock有两个重要方法,一个是acquire(),一个是release(),前者表示上锁,后者表示释放锁。有一点需要注意:同一个线程中,上锁之前锁必须是解开的,不然会造成死锁。开头那段代码修改后成了下面这样:

import threading

def increase(lock):
    global num
    for i in range(1000000):
        lock.acquire()
        num += 1
        lock.release()

def decrease(lock):
    global num
    for i in range(1000000):
        lock.acquire()
        num -= 1
        lock.release()

if __name__ == "__main__":
    num = 0

    lock = threading.Lock()
    inc = threading.Thread(target=increase, args=(lock,))
    dec = threading.Thread(target=decrease, args=(lock,))

    inc.start()
    dec.start()
    inc.join()
    dec.join()
    print(num)

此时程序的运行结果总为0,但程序的运行速度也变慢了。这是因为解释器执行到acquire()时会锁住共享资源。假设increase()先执行到了acquire()(锁住资源),它就拥有先对num操作的权力,直到它执行release()后(释放资源),其他线程才可以使用num,也就是说LOAD_GLOBAL->STORE_GLOBAL这个过程从原本的异步变成了同步,所以程序的运行速度变慢。

查看源码发现Lock还实现了__enter____exit__,因此我们可以用with进行上下文管理,代码得到简化:

...
def increase(lock):
    global num
    for i in range(1000000):
        with lock:
            num += 1
...

判断锁是否为“锁住状态”,可用locked():

import threading

lock = threading.Lock()

lock.acquire()  # 上锁
print(lock.locked())  # 输出:True

lock.release()  # 解锁
print(lock.locked())  # 输出:False

RLock

RLock是可重入锁,它与Lock最大的区别在于,RLock允许同一个线程中重复上锁,程序不会发生死锁:

import threading

lock = threading.RLock()

print("step1...")
lock.acquire()
lock.acquire()
print("step2...")

lock.release()
print("step3...")
lock.release()

# 输出:
step1...
step2...
step3...

但需要注意的是,每个acquire()都应该有一个与之对应的release()

以下是RLock中acquire()的实现源码。它通过跟踪宿主线程获取me来判断上锁的线程是不是自己,如果是,引用加1(self._count += 1),如果不是,上锁(self._block.acquire(blocking, timeout))。源码中的self._block其实就是Lock的实例对象。

def acquire(self, blocking=True, timeout=-1):
    me = get_ident()
    if self._owner == me:
        self._count += 1
        return 1
    rc = self._block.acquire(blocking, timeout)
    if rc:
        self._owner = me
        self._count = 1
    return rc

总之,在通常情况下使用RLock比Lock更安全。

感谢



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论