多线程环境下锁的使用

从小到大就被灌输多线程是很难搞的,事实也确实如此。

但大部分时候我们不需要去写库函数,一般只是简单的使用第三方提供的函数。

涉及到lock的情况,一定要搞清楚这个lock保护的是什么。 如果没搞明白这个,迟早要还的。如果能做到这点,可保平安。

multithread, condition signal, mutex什么的概念随处可见,相关文档的研究也非常普及。
假设大家都或多或少了解一些。这里只对最普通的mutex lock的使用进行一点经验性的简单说明。

mutex的目的是为了阻止条件竞争/竞争危害。** 方式是把有可能被意外修改的资源放进逻辑上的临界区**。

临界区是同一时刻只有一个线程可以进去执行。
如果已经有线程在临界区里执行(条件1),其他线程如果也要执行临界区的代码(条件2),那么后来的线程只能等待临界区的线程离开。

临界区是一段受保护的代码区域的执行。具体到我们写代码时,是

doFoo()
obj.lock()
obj.doWork()
obj.unlock()
doBar()

第2到第4行是逻辑上的临界区。通过mylock这个mutex来保护doWork函数的执行。

一般教科书会使用具体的一个全局变量count之类的来做示例。这样能很清楚的看到保护的到底是什么东西。但实际写代码时特别是使用第三方库时往往不会直接把某个简单的变量暴露给你使用,而是通过一些api函数来提供操作,也就是类似这种doWork()的形式,往往也是因为有这么一层封装,导致我们经常忘记被实质保护的东西到底是什么,而仅仅只是在出问题时去尝试加上lock(),unlock()这对“万金油”。

代码和数据

我们暂时忘记lock这个东西。来回忆下数据和代码。
我们所写的代码,包括上面那个临界区都是一个函数的一部分,他们都会被编译到.text这个section中。在进程执行时被load到一个只可以读和执行的vma上。
而obj本身的创建一般是在heap上,heap被load到一个可读可写(一般情况下是不可执行的)的vma上。

临界区保护的是什么?

我们说的条件竞争是说读/写顺序可能和程序员脑子里设计的顺序不一致导致的意外发生。(在不同层面细究下为何会导致这种不一致也是很有帮助的)

那么如果.text的数据在执行时根本就不会被写入,那么代码本身的内容在执行时就已经确定了。那么它本身就不存在条件竞争了,也就不存在临界区什么事了。
也就是说临界区保护的一定不是代码片段,虽然我们定义逻辑区的范围是通过代码段来的。

临界区保护的是某个变量(实际不准确,比如还有很多类似硬件IO寄存器,网络状态等等情况)。并且这个变量还需要满足一定条件。
1. 存在多个线程同时访问它们。
2. 可写且有线程去写。(如果只是读,那么也不存在竞争了)
满足这些条件的变量有全局变量和静态变量以及heap上创建的变量。局部变量因为无法满足条件1,因此是不需要被保护的。

全局变量和静态变量一般会被封装为thread-saftey, 我们遇到的往往是heap上创建的变量。而这种变量一般都是一个具体的结构体(很少是int,double这类简单类型)。

何时该操作lock

理解了临界区保护的是什么后,我们就知道,如果我们与第三方库交互涉及到lock/unlock操作,那么一定是有一个以上的线程在进行操作。

那么我们应该知道些什么呢?

  1. 除了你自己的线程,另外还有哪些竞争的线程呢?
  2. 这些线程是何时何地被创建的呢?
  3. 这些线程在何时会进入临界区(lock),在何时会退出临界区(unlock)?

一定要搞清楚这3个问题,否则就是在多线程的黑夜里摸瞎。

这里举两个具体的例子。来回到以上三个问题

第一个是libpulse的threaded_mainloop,第二个是glib的mainloop。
要理解这两个东西,首先你得自行去理解poll(2)的使用和相关概念。

这类mainloop的大致逻辑是

while(running) { prepare(); poll(); dispatch(); }

什么意思呢?
1. while(running)是说只有loop没有quit那么就一直循环。一直循环什么意思呢?就是说得有一个单独的线程来跑它,因为它基本是不会结束的。
2. prepare() 这个类似为poll(2)准备需要监听的fds,也就是本次迭代时你关系什么消息。什么意思呢?就是说收集下所有你感兴趣的事件,把这些事件列表(还未发生,也不一定会一定发生)以及相关的信息比如回调函数呀,之类的整理到一起然后传递给poll()
3. 根据prepare的兴趣列表,调用poll() (一般是通过kernel)后进行休眠等待。执行poll的时候一般还会增加一个timeout参数,避免太长时间没有事件来导致这一阶段无法结束。
4. 如果poll()因感兴趣的事件到来则,使用dispatch()把对应事件下发,一般是执行对应事件的回调函数。

Q1 有哪些竞争的线程呢?

不论是pa threaded mainloop 还是glib mainloop 都需要一个“阻塞”的线程去执行loop操作。

Q2 这些线程是何时何地被创建的呢?

pa_threaded_mainloop_start()会单独创建一个thread并去执行整个while(running)...;

gtk_main(), g_main_loop_run()会在当前线程下执行整个while(running)...

Q3 这些线程在何时会进入临界区(lock),在何时会退出临界区(unlock)?

两者都是类似的,
1. 在loop启动时lock
2. 在poll执行前unlock
3. 在poll结束后lock
4. 在loop结束时unlock

1,4基本是在进程启动和结束时执行的,往往可以忽略。重点是2,3和poll相关的。 为什么是

unlock()
poll()
lock()

这种形式?
原因很简单,如果你回头想想上面说的lock是干嘛的就知道了。如果整个loop阶段都使用lock来保护,那么整个lock就没有存在的意义了。
loop必须在某个阶段unlock,这样用户的代码才能去注册/删除一些事件,以此影响prepare()和dispatch()的行为。而poll()主要干的事是休眠,这时拿着锁也是没有意义的。

上面只说了loop是如何进入和退出临界区的。那么“用户线程”是何时进入和退出的呢?
这个需要进一步了解一丢丢context和mainloop的关系。

libpulse里面是 pa_context和pa_threaded_mainloop;glib里面是GMainContext和GMainloop;
context封装了被注册的时间以及对应回调等数据,loop封装了while(true)这个行为
所以其实我们保护的是context这个结构体(数据),loop本身只是一个固定的行为,是不需要被保护的。(只是往往lock/unlock是通过loop来暴露的,所以容易引起想当然)

因此凡是(如果是一把大锁)我们操作context时都需要进入临界区,操作完后需要退出临界区。那么context有哪些数据是我们可以操作的呢?
比如注册监听某个事件,取消监听,创建pa_stream(libpulse); g_main_context_foobar(glib)。

ps: GMainContext在内部已经做了thread-saftey的处理,并没有对外暴露lock相关操作。但GObject对象本身是没有任何thread-saftey的,包括基于GObject之上的gsignal相关操作。

1 条思考于 “多线程环境下锁的使用

发表评论

电子邮件地址不会被公开。 必填项已用*标注