多线程编程

线程同步

当多个线程共享同一块内存区域时, 我们需要保证任何一个线程在访问这块内存时, 所看到的内容是稳定的. 如果所有的线程对这块内存的访问都只是读取, 那么我们就不需要采取额外措施. 但哪怕其中有一个线程会修改这块内存, 那我们就要对这些线程进行同步.

之所以要这么做是因为修改内存的操作往往都不是原子操作, 而是分成多个时钟周期, 一个线程对内存的操作可能在任何一个周期被另一个线程打断, 从而导致这块内存的内容不稳定.

所谓线程同步, 也就是说当好几个线程同时想要操作某块内存时, 它们必须按照顺序一个一个来, 只有上一个线程对这块内存操作完毕了, 下一个线程才能接着对这块内存进行操作.

如何实现线程同步呢? 就是加锁.

如何加锁?

那么什么是加锁? 对谁加锁? 怎么加锁?

我们的目的是对这块内存加锁, 好让某个线程操作这块内存时, 其他线程进不来.

但是这把锁得是什么形式的呢? 物理上来讲, 是处理器上有这么个电子器件吗? 那显然不是的, 这把锁是软件层面的锁.

那么为了锁住这块内存, 我们需要仍然在这些线程都能够访问到的内存区 — 也就是进程的内存空间中选块内存作为一个开关, 假设这个开关内存为 L, 这些线程都要操作的内存叫 A. 现在我们的代码中要这么写, 每个线程在操作 A 之前, 都要先去看看 L 那边值是几, 如果是 1 的话就将其改成 0, 然后访问 A, 访问完了之后就再将 L 改成 1; 如果 L 是 0 的话, 就说明有别的线程正在操作 A, 那我就捞不着操作了, 先干别的过会儿再来看看吧.

你可能注意到了, 不对啊, L 也只不过是进程空间的一块内存, 和 A 一样都是这些线程们能够共享的内存, 那我一个线程修改 L 的时候, 别的线程就不会来修改 L? 线程一取 L 的值进寄存器, 发现是 1, 于是在寄存器上改为 0, 然后正准备要写回去, 结果线程二打断了它, 也取了 L 的值发现是 1, 于是后面不就又两个线程同时访问 A 内存了吗? 这锁不管用啊!

你能想到这些很了不起, 先别急等我说完嘛. 操作 L 和操作 A 实际还真是不一样的, 操作 L 和操作 A 虽然都是在操作进程空间的内存, 但实际上操作 L 比操作 A 多了一个步骤, 那就是 — 挂起调度器 [1] , 这可是很不得了的事情, 一旦执行了这个操作, 其它的线程 [2] 就再也捞不着执行了, 这样当前操作 L 的线程就可以放心的修改 L 了, 绝对不会被打断, 当然改完 L 之后一定要恢复调度器的.

你可能又会问了, 既然还有挂起调度器这一招, 那还折腾出个 L 干什么? 直接线程在访问 A 的时候就挂起调度器, 访问完了 A 就恢复调度器不就行了吗?!

这个自然是不行的, L 这款内存只是存个标记, 只需要不到 1 个字节就够, A 这块内存大小是不一定的, 可能就几个字节, 但也可能大到好几兆, 操作几兆的内存和操作几字节的内存量级上可是 100 万倍的差异啊. 假设访问一个字节是 1us, 那么访问一兆字节我们不妨认为需要 10^6us = 1s. 如果每个线程访问 A 都要把可能原本也没有打算访问 A 的, 无辜躺枪的其它线程全都挂起 1s, 这还有王法吗, 还有法律吗?

标注

[1]

说挂起调度器实际上不严谨, linux 是个复杂的操作系统, 我从嵌入式系统的角度来解释一下. 我们知道处理器有很多中断, 其中时钟中断是源自晶振的非常规律的周期性中断, 操作系统内核为了能够支持多任务, 需要利用这一点, 在每个时钟中断到来之时, 切换 CPU 上下文, 执行另一个任务. 我们可以认为, 捕获时钟中断, 并执行任务切换工作的这段代码就是调度器, 而挂起调度器什么呢? 意味着时钟中断将成为一个摆设, 除了挂起调度器的那个任务, 所有其他任务都将再也得不到执行. 所以, 挂起调度器是一个影响巨大的操作, 负责挂起调度器的那个任务, 一定, 必须, 绝对要负责随后恢复调度器!

从嵌入式开发的层面来看, 挂起调度器实际上也只是触及到了操作系统内核的工作, 还没有介入到处理器这一层, 时钟中断实际上还在不断的产生, ISR (中断处理函数) 也会被调用, 只是操作系统内核不再去处理了罢了. 比这影响更大的就是关中断, 这个就不扯远了. 关于这块内容, 可以回顾一下 FreeRTOS 的源代码.

从应用程序层面看, 挂起调度器这个操作已经足够危险了, 所以在 Linux 这样的操作系统中, 应用程序是没有权限做这个事情的, 必须要通过系统调用委托内核去做才行.

[2]指的是属于当前进程的其它线程们

加锁与解锁顺序

当有两个或以上的线程需要同时获得两个以上的锁时, 获得这些锁的顺序必须是一定的. 否则可能会引起死锁问题. 但是释放这锁的顺序就无所谓了.

读写锁