tylderen +

Linux系列: 线程同步机制

在多线程编程中,永远绕不开的话题就是线程安全以及线程之间的同步问题。

在我看来,这些问题的根源都是因为,在多线程环境下,程序执行顺序的不确定性。也就是说,在程序运行中,我们永远不知道在下一个时间点哪个线程会执行,以及每个线程执行到哪了。这是造成多线程编程变得异常复杂的根本原因。

试想,在单线程的环境下,我们只需要按照自己的思路一步一步写代码,在写的过程中,就可以推测出这一行执行完,下一步会执行哪一行。这样,代码写完了,程序在脑海中也执行完了。

在多线程编程中,除了需要理清单个线程内的运行逻辑,又要考虑不同线程之间的交互(也许这些交互并不是你想要的)。这些交互的地方,从代码的角度来看,也许是某一个公共变量,也许是某一个函数,等等。

多线程的问题是伴随着它的使用天生而来的,对付它的办法看起来也不少,像原子操作,锁,信号量,读写锁等等。

原子操作

原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为”不可被中断的一个或一系列操作”。 从概念上来看,就可以知道:

  • 它是最小的执行单位,不可能有比它更小的执行单位;
  • 操作不会在执行完毕前被任何其他任务或事件打断;

原子操作在多线程里,意味着这个操作一旦开始,就不会被打断,更不会被其它线程抢断。所以,如果我们知道某个操作是原子性的,就大可放心,不用额外的添加锁。所以也是很省心的。

Python虚拟机里面的全局锁能保证Python bytecode的原子性,所以,我们可以认为,一个bytecode就是一个原子操作。 来看看我们最常用的赋值和加1操作分别需要几个bytecode

In [1]: import dis

In [2]: def test():
   ...:     a = 1
   ...:     a += 1
   ...:

In [3]: dis.dis(test)
  2           0 LOAD_CONST               1 (1)
              3 STORE_FAST               0 (a)

  3           6 LOAD_FAST                0 (a)
              9 LOAD_CONST               1 (1)
             12 INPLACE_ADD
             13 STORE_FAST               0 (a)

             16 LOAD_CONST               0 (None)
             19 RETURN_VALUE

能看出来,Python里简单,基本的赋值操作,需要两个bytecode

  2           0 LOAD_CONST               1 (1)
              3 STORE_FAST               0 (a)

但是我们依然认为它是原子的,因为真正的赋值语句其实是第二行。

而加1的操作,需要四个bytecode

  3           6 LOAD_FAST                0 (a)
              9 LOAD_CONST               1 (1)
             12 INPLACE_ADD                    # 先进行加一操作
             13 STORE_FAST               0 (a) # 然后进行赋值操作 

即: read, update and write;

如果当前线程读完a的值之后,又有其他线程对a进行了加1操作,然后切换到当前线程,那么还是在原来的基础上加1, 相当于两次加1操作仍然得到的是一次加1的结果。所以可以看出来,加1操作并不是原子操作。

事实上,进行多线程编程时,也别太指望某个操作是原子操作,老老实实的考虑使用显式的锁才是最可靠的办法。 所以原则就是:

When in doubt, use explicit locks.

原子操作大多数时候都会是一种奢望。使用锁基本上就成了不二选择。

在Python官方文档对 Lock Object 的介绍里面,有这么一句话:

A primitive lock is a synchronization primitive that is not owned by a particular thread when locked. In Python, it is currently the lowest level synchronization primitive available, implemented directly by the thread extension module.

使用也比较简单,就两个方法:

Lock.acquire([blocking])

Lock.release()

事实上,我们接下来谈到的所有多线程同步方法,实现起来都会用到这个锁。

可重入锁

是指同一个线程可以多次获得同一个锁,其它线程只有在这个锁释放了才能 acquire(),否则会一直阻塞在这里。 所以,也可以说,可重入锁的获取粒度是线程,而普通的锁在再次调用的时候就会有效果。

RLock Objects

A reentrant lock is a synchronization primitive that may be acquired multiple times by the same thread. Internally, it uses the concepts of “owning thread” and “recursion level” in addition to the locked/unlocked state used by primitive locks. In the locked state, some thread owns the lock; in the unlocked state, no thread owns it.

它的实现比较简单,只是在上一个锁的基础上,加入了计数器,判断当前线程获取锁的次数,当次数为0时释放锁。所以需要注意,每次都要保证获取的锁能够释放掉。

Condition

相比于上面的两个锁, Condition提供了线程之间的通知功能。 实现这个功能,需要两个方法:

wait([timeout])

Wait until notified or until a timeout occurs. This method releases the underlying lock, and then blocks until it is awakened by a notify() or notifyAll() call for the same condition variable in another thread, or until the optional timeout occurs. Once awakened or timed out, it re-acquires the lock and returns.

notify(n=1)

By default, wake up one thread waiting on this condition, if any. Note: an awakened thread does not actually return from its wait() call until it can reacquire the lock. Since notify() does not release the lock, its caller should.

这种线程间的通知机制非常容易让人想起协程,正常情况下都是主动放弃当前的资源让给另一个线程去运行。

信号量 Semaphore

信号量和可重入锁在实现上有点像,都是在锁的基础上增加计数器,只不过信号量的计数器是在做减法。

A semaphore manages an internal counter which is decremented by each acquire() call and incremented by each release() call. The counter can never go below zero; when acquire() finds that it is zero, it blocks, waiting until some other thread calls release().

它是刚开始定义好一个计数器,每次 acquire(), 计数器都会减1,直到为0就会阻塞,除非其它地方调用了release()。所以信号量比较适合预先设定对某个共享资源的最大同时访问数量。

下一篇会用代码示例介绍一下各种锁的使用。

Blog

Opinion

Project