为何需要临界区

多个线程共享地址空间,便利的数据访问也使执行顺序影响结果。例如,对一个普通计数器加一,可能分成读取、计算和写回三个步骤。若两个线程都读到旧值,再各自写回旧值加一,两个增量就可能只留下一个。即使只有一个处理器,线程切换也可能形成这种交错。

临界区是需要限制这种相互干扰的代码区域。互斥所要求的不是整台机器同一时刻只能运行一个线程,而是对同一受保护状态执行相关操作的线程不能同时处于相应临界区。它控制的是允许出现的执行交错。

参考:[2] [1]

锁保护的是访问协议

通常的用法是在读取或修改受保护状态之前取得同一把锁,完成操作后释放它。其他争用者只能等锁可用后进入。保护范围应覆盖需要保持一致的整个操作,而不只是最后一条写入语句;否则此前的判断仍可能基于已经过时的状态。

锁不会为一块数据自动建立不可绕过的屏障。若某条访问路径忽略锁,或使用与其他访问者不同的锁,保护约定就可能失效。反过来,彼此独立的共享结构可以使用不同的锁,保留并行机会。锁粒度因此是正确性与并发程度之间的重要设计选择。

等待条件成立时,不能总是抱着互斥锁停住,否则能改变条件的线程也可能进不来。Rust的条件变量wait会原子地释放配套互斥锁并进入等待,返回前重新取得锁;调用者随后在锁内重查条件,并通常用循环等待,因为唤醒可能是伪唤醒。通知本身也不缓存为未来许可。这样,锁负责保护共享判断与更新,条件等待负责让出执行机会,两者合起来表达完整协议。

参考:[1] [2] [4]

接口与实现不能混为一谈

POSIX 的 pthread_mutex_lock 在互斥量由其他线程持有时阻塞,取得后使调用线程成为持有者;pthread_mutex_trylock 则在无法立即取得时返回,而不是继续等待。递归互斥量允许同一持有者重复取得,并以计数决定何时真正释放;不能把这种行为推广到所有互斥量类型。

锁的底层可以借助原子测试并设置等操作,也可以配合操作系统等待机制。简单地先读一个“空闲”标志,再另行把它写成“占用”,会在两步之间留下竞争窗口。自旋锁通过反复尝试等待,因而可能消耗处理器时间;互斥这一性质本身并不指定唯一实现。

参考:[3] [1]

排他不等于公平

互斥回答的是“是否会同时进入”,而公平和避免饥饿关心“等待者何时能够进入”。一把锁即使从未允许两个线程同时持有,也可能让某个等待者反复输掉竞争。简单测试并设置自旋锁就是不能仅凭排他性推断公平性的例子。

POSIX 接口说明,锁释放后由调度策略决定哪个等待线程取得它,不能据此假定先进先出。应用还必须正确处理返回值和持有者异常终止等情况;健壮互斥量可以报告此前持有者死亡,但受保护状态是否可恢复仍需要应用判断。锁保证与业务状态正确性不是一回事。

参考:[1] [3]