本文转载自知乎,原文链接:https://www.zhihu.com/question/54557903

如题,虽然我之前每个都加锁,但是我一直有个心病 就是我这种写代码姿势到底对不对。我看不少项目也和我是一样的。
但是如果变得 N多锁,我就会觉得 为了所谓的并行,代码搞这么复杂,感觉好恶心。。。。(当然go语言写某些程序 例如网络 由于是阻塞读写 几乎必须并行)
比如 我要实现一个最大连接数。
1.每次新的连接的时CurrentCount要自增+1 要加锁。
2.完了再次调用Accept的时候要检查CurrentCount是否大于MaxCount 要加锁。
3.有某个连接断开CurrentCount要自减-1 要加锁。
4.显示当前有几个连接CurrentCount 要加锁。
5.如果我希望动态改变MaxCount那么MaxCount改变的时候要加锁,那我还要在2处锁住MaxCount 要加锁
6.SessionMgr 内部实现又是要加锁 增删查 每个动作都要加锁。
7.如果我要把 Session 发过来的封包 做转发 我还要锁住 另一个 Client 检查状态是否断开
8.比如我还想让服务器的一个模块暂停工作 要加锁(虽然这功能看上去很奇葩)。
9.遍历session的时候又要加锁。
10.内心无数个--锁 飞过。

锁一多心好累。大家写多线程(并行)代码都是这样的吗?只要想在多线程下多加点功能 并且想精细化控制 就感觉离不开锁。无限加锁?绕不开吗?


需不需要加锁,完全取决于场景,安全和效率,原子性和可见性的取舍。以 Java 为例子:

  1. 只读型或者不可变数据,无需任何加锁。这时候类似 clojure 里的不可变数据结构的优势就体现出来了。
  2. 只确保可见性,使用 volatile 声明。
  3. 同时确保可见性和原子性,synchronized 和 lock 等。

针对更具体的场景还能展开:

  1. 读多写少的场景,引入读写锁或者 CopyOnWrite(结合 volatile) 技术。比如题目中的 session 集合的处理。
  2. 计数型操作,使用 Atomic 类组,或者 Java8 引入的 Addr 类,都是现代 CPU 提供的原子指令 。比如题目中的 session 计数。
  3. 不同变量独立保护,分拆锁 (lock splitting) 和分离锁 (lock striping)。比如 session 集合是否可以采用分离锁来分段保护, Java 可以直接用 ConcurrentHashMap 。
  4. 处理资源有限,可以异步处理的,生产者-消费者模型,中间的队列也可以用一些 lock-free 算法。消费者如果就一个,临界区的保护也可以做到适当缩减。比如消息转发之类可以采用生产者消费者模型,最常见就是线程池。
  5. 临界区处理很快,也许用 spin lock 或者 lock-free 算法,临界区处理很慢, mutex 更合适。

我觉的并发编程很大程度上就是针对这些具体场景,在安全和效率、原子性和可见性之间做各种取舍,对于性能,更重要的是实际的测量,而非臆测。


题主的问题非常有代表性,多线程访问共享资源一定要加锁么,有没有其他方式,加锁后程序的并发还能有多好?

首先,多线程访问共享资源是一定需要一种同步方式的,否则程序就不会按照我们的预期做事,我们所能做的就是选择怎么样的同步方式。

不同的同步方式其性能差异非常大,同步方式包有语言层面提供的原子变量、自旋锁、互斥锁、信号量、读写锁等,下面简单讲一下各自的实现,只有清楚了各个同步机制的实现,在业务中使用锁的时候才能做到心里不慌,同时,在业务开发中注意配合锁的优化实现,比如,让临界区尽可能
小,只对真正需要代码逻辑加锁而不是对整个函数加锁,这样一来,即使加锁了,可能也只是spin一会儿,并不会引起当前线程进行上下文切换从而进入等待状态。

  • 原子变量(比如C++里面的atomic_llong)由处理器的原子指令完成变量的读写,速度是最快的。下面有一个小程序进行了对比,相比读写锁,速度快了不止一个数量级。题主CurrentCount、MaxCount的读写可以考虑用原子变量。
  • 自旋锁,是一种busy-waiting机制,当拥有自旋锁的线程能很快完成自己的事情并释放自旋锁,其他线程只需几次check判断就可以获得自旋锁,避免了非常耗时的线程上下文切换和状态的迁移(从运行态变为等待)。Linux系统采用queued spinlocks,可以允许每个处理器在spin的时候只check自己内存的地址变量。底层借助原子变量实现。
  • 互斥锁,当没有其他线程占用互斥锁的时候,获取锁非常简单,直接将count减1即可,释放锁则直接加1。当只有锁的拥有者线程在运行、且没有其他高优先级的线程准备运行的时候,当前线程获取互斥锁的时候,互斥锁会spin一会儿(有次数限制,避免一直spin),待锁的拥有者线程
    执行完毕释放锁后,当前线程可直接获得互斥锁,避免了非常耗时的上下文切换和线程状态切换。当上面两个情况都不满足后,当前线程会被放入等待队列,这种情况会非常耗时。互斥锁底层借助原子变量和自旋锁来实现。
  • 信号量,分二进制信号量和普通信号量,二进制信号量可用于资源的互斥访问,和互斥锁类似。普通信号量一般用于资源的分配和释放,例如线程池。信号量提供了多个api,有阻塞式的API如down_interruptibledown_killabledown_timeout,也有非阻塞式的API如down_trylock。底层借助自旋锁来实现。
  • 读写锁,底层通常借助于互斥锁和条件变量或者信号量来实现。当没有写操作的时候,多个读操作可以并发执行,当有写操作的时候,其他读或者写操作需要等待直到当前的写操作完成。读写锁根据实现策略不同,包括读优先(最大并发)、写优先(避免写饿死)、读写同等三种情况。

标签: none

评论已关闭