本质。我大言不惭来一句:多线程问题,核心就在 是 资源的不隔离和执行顺序的不确定 引发的。
资源的不隔离:在多线程环境中,多个线程可能会访问和操作共享资源。如果没有合适的同步机制来隔离资源,多个线程对共享资源的访问和操作可能会相互干扰,导致数据不一致或其他错误。
执行顺序的不确定:多线程程序中的线程可能会并发地执行,这意味着它们的执行顺序是不确定的。由于执行顺序的不确定性,线程之间可能会发生竞态条件,从而导致程序执行结果出现错误。
细致再分一下地说,多线程问题主要是由以下几个原因导致的:
1. 资源共享:在多线程环境中,多个线程可能需要访问和操作共享资源(如变量、数据结构、文件等)。如果没有合适的同步机制,多个线程对共享资源的访问和操作可能会相互干扰,导致数据不一致或其他错误。
2. 并发执行:多线程程序中的线程可能会并发地执行,这意味着它们的执行顺序是不确定的。由于执行顺序的不确定性,线程之间可能会发生竞态条件,从而导致程序执行结果出现错误。
3. 依赖关系:多线程程序中,线程之间可能存在依赖关系,即一个线程需要等待另一个线程完成某个任务后才能继续执行。如果这些依赖关系没有得到正确的处理,可能会导致死锁或活锁等问题。
4. 编译器和处理器优化:为了提高程序性能,编译器和处理器可能会对程序进行优化,如指令重排、缓存一致性等。这些优化可能会导致多线程程序中的内存访问顺序与预期不符,从而引发线程安全问题。
因此,在多线程编程中,为了确保线程安全,我们需要采用适当的同步和通信机制来解决这些问题,如使用互斥锁、信号量、原子操作等来保护共享资源,以及使用条件变量、事件等机制来处理线程间的依赖关系。
首先补充一个基础知识:
现实编程过程中,加锁通常会严重地影响性能。线程会因为竞争不到锁而被挂起,等锁被释放的时候,线程又会被恢复,这个过程中存在着很大的开销,并且通常会有较长时间的中断,因为当一个线程正在等待锁时,它不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行,例如发生了缺页错误、调度延迟或者其它类似情况,那么所有需要这个锁的线程都无法执行下去。如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,就会发生优先级反转。
Disruptor论文中讲述了一个实验:
- 这个测试程序调用了一个函数,该函数会对一个64位的计数器循环自增5亿次。
- 机器环境:2.4G 6核
- 运算: 64位的计数器累加5亿次
|Method | Time (ms) | |— | —| |Single thread | 300| |Single thread with CAS | 5,700| |Single thread with lock | 10,000| |Single thread with volatile write | 4,700| |Two threads with CAS | 30,000| |Two threads with lock | 224,000|
CAS操作比单线程无锁慢了1个数量级;有锁且多线程并发的情况下,速度比单线程无锁慢3个数量级。可见无锁速度最快。
单线程情况下,不加锁的性能 > CAS操作的性能 > 加锁的性能。
在多线程情况下,为了保证线程安全,必须使用CAS或锁,这种情况下,CAS的性能超过锁的性能,前者大约是后者的8倍。
综上可知,加锁的性能是最差的。
好的框架如何解决多线程问题:
netty [哎,我就是单线程执行任务(单线程池组)!],netty使用单线程事件的循环模型来处理IO操作,用主从reactor模型处理连接和分发io操作。利用任务队列来处理特定EventLoop上执行的任务,Netty将任务分组到不同的EventLoop上,确保同一类任务都在同一个EventLoop的队列上执行。(这样可以保证任务执行的顺序性和一致性,同时避免多线程竞争和同步问题。每个EventLoop都绑定到一个特定的线程,任务队列中的任务会在该线程上顺序执行。当一个任务完成后,EventLoop会从任务队列中取出下一个任务继续执行。这种设计使得任务在正确的线程上按照它们被添加到队列的顺序执行,保证了任务之间的相互独立性。通过将任务分配到不同的EventLoop,Netty可以充分利用多核处理器的性能,实现高并发和高性能的网络应用。同时,这种事件驱动的模型使得EventLoop能够在空闲时节省CPU资源,从而提高整体性能。) 在部门的多线程上报物模型(wid)上也使用了这种做法。 hash wid值,打到对应的线程池上的线程上,保证同一个wid上报的消息能顺序执行。 所有的消息也不一定要进行计算,高并发下比对,json的md5值直接不同就说明有变动。
在Netty中,可以使用io.netty.util.concurrent.EventExecutorChooser接口来实现类似的功能。EventExecutorChooser负责从EventExecutorGroup中选择一个EventExecutor来执行任务。可以自定义实现EventExecutorChooser接口,使用哈希函数和模运算来选择合适的EventExecutor。
redis 巧了,我也是单线程处理执行命令(网络可是基于reactor的多线程)
disruptor 哎嗨,我就是无锁,利用内存屏障,只要在关键节点上让他有读写屏障,其余部分随便它优化。
- 数据结构。Ring Buffer 是一个无锁的数据结构,这意味着 Disruptor 不需要使用锁来保护数据。这样可以减少线程之间的竞争,提高并发性能。
- 分离生产者和消费者:Disruptor 将生产者和消费者的角色进行了分离。生产者只负责将数据写入 Ring Buffer,而消费者负责从 Ring Buffer 中读取数据。这种分离可以确保生产者和消费者之间不会发生直接的竞争,从而降低了线程安全问题的风险。
- 顺序访问:Disruptor 使用了一种名为 Sequence 的原子计数器来记录生产者和消费者的位置。生产者和消费者通过更新 Sequence 来进行顺序访问,这样可以确保数据在 Ring Buffer 中的顺序一致性,避免数据不一致的问题。
HikariPool无锁设计
每个HikariPool里都维护一个ConcurrentBag对象,用于存放连接对象,由上图可以看到,实际上HikariPool的getConnection就是从ConcurrentBag里获取连接的(调用其borrow方法获得,对应ConnectionBag主流程),在长连接检查这块,与之前说的Druid不同,这里的长连接判活检查在连接对象没有被标记为“已丢弃”时,只要距离上次使用超过500ms每次取出都会进行检查(500ms是默认值,可通过配置com.zaxxer.hikari.aliveBypassWindowMs的系统参数来控制),也就是说HikariCP对长连接的活性检查很频繁,但是其并发性能依旧优于Druid,说明频繁的长连接检查并不是导致连接池性能高低的关键所在。
这个其实是由于HikariCP的无锁实现,在高并发时对CPU的负载没有其他连接池那么高而产生的并发性能差异,后面会说HikariCP的具体做法,即使是Druid,在获取连接、生成连接、归还连接时都进行了锁控制,因为通过上篇解析Druid的文章可以知道,Druid里的连接池资源是多线程共享的,不可避免的会有锁竞争,有锁竞争意味着线程状态的变化会很频繁,线程状态变化频繁意味着CPU上下文切换也将会很频繁。