多线程情况下 很多变量 频繁访问 难道每个都要加锁访问吗?(转载)
本文转载自知乎,原文链接: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 为例子:
- 只读型或者不可变数据,无需任何加锁。这时候类似 clojure 里的不可变数据结构的优势就体现出来了。
- 只确保可见性,使用 volatile 声明。
- 同时确保可见性和原子性,synchronized 和 lock 等。
针对更具体的场景还能展开:
- 读多写少的场景,引入读写锁或者 CopyOnWrite(结合 volatile) 技术。比如题目中的 session 集合的处理。
- 计数型操作,使用 Atomic 类组,或者 Java8 引入的 Addr 类,都是现代 CPU 提供的原子指令 。比如题目中的 session 计数。
- 不同变量独立保护,分拆锁 (lock splitting) 和分离锁 (lock striping)。比如 session 集合是否可以采用分离锁来分段保护, Java 可以直接用 ConcurrentHashMap 。
- 处理资源有限,可以异步处理的,生产者-消费者模型,中间的队列也可以用一些 lock-free 算法。消费者如果就一个,临界区的保护也可以做到适当缩减。比如消息转发之类可以采用生产者消费者模型,最常见就是线程池。
- 临界区处理很快,也许用 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_interruptible、down_killable、down_timeout,也有非阻塞式的API如down_trylock。底层借助自旋锁来实现。 - 读写锁,底层通常借助于互斥锁和条件变量或者信号量来实现。当没有写操作的时候,多个读操作可以并发执行,当有写操作的时候,其他读或者写操作需要等待直到当前的写操作完成。读写锁根据实现策略不同,包括读优先(最大并发)、写优先(避免写饿死)、读写同等三种情况。
评论已关闭