单核的时代并发更多的是为了应对IO密集型的场景,而如今多核的背景下,CPU-Bound的场景下开启多线程更多是为了并行(parallelism)的效果,一个不涉及IO的任务,如果能够拆成2份,两个CPU各自完成其中一部分,理论上来说整体耗时自然会除以2,但是这有前提条件:被拆分的任务运算的过程是不相干的,最终的运算结果可能有一个合并操作(这是可以忽略不计的)。如果说子任务的计算过程涉及到同一块数据结构,那么耗时是肯定不能做到减半的效果,因为涉及到同一块数据结构,那么通常来说访问这块数据需要加锁,也就意味着,计算过程中可能会涉及大量的线程被挂起-恢复的过程,并且比如某些数据可以是走CPU-cache的,由于加了锁,每次数据都需要从主存中获取,这些带来的开销某种程度上抵消了并行度带来的福利,甚至来说耗时大于一个线程去完成整个计算---因为这样不需要任何并发控制,所以CPU密集操作的场景下,理想的并发就是子任务执行过程不相干,各自进行各自的计算,数据,最终不同的线程将结果进行merge得出总结果,并且还可以考虑到CPU亲缘性,线程绑定在同一个CPU上跑,数据可能走的都是各自cpu的cache。而对于IO密集型的场景,开启多少线程去处理通常就不一定了,可能可以参考一些公式,更多的可能需要自己进行测试出能有最好效果的最佳的线程数。

  Java的并发模型是比较复杂繁琐的,抽象级别是比较低的,JRS-133 描述了Java内存模型与线程规范,JLS17则阐述了一些线程和锁的特性,还有一系列happens-before原则,对于我们开发者可能更多熟悉的是volatile带来的语义:对于volatile变量的写happens before对其的读,这意味着如果某个变量同时被多个线程操作,但是只有一个线程会进行写操作,那么使用volatile就可以确保对于这个变量的操作是线程安全的,你不必担心某个线程读到旧的值,甚至读到才更新了一部分的变量。

hb(x, y) and hb(y, z), then hb(x, z),有了这个特性,再结合另一个:If x and y are actions of the same thread and x comes before y in program order, then hb(x, y),利用这两个原则,很多时候就可以建立出happens-before edge确保线程安全性,可以看下这个问题。

  Java的线程和操作系统的线程是1:1的关系,这意味着Java中线程是比较昂贵的资源,并不能无脑的启动,这也引入了线程池这个工具,但是可能线程池依旧不够,比如网络IO场景下,如果使用Java写一个服务器,在并发不是很高的情况下,每个client的处理自然可以投递到线程池中,但是并发很高的话,线程池也会力不从心,所以现在通常借助于NIO,一个线程就可以handle住所有客户端的连接,读写事件,如果所有后续操作都是CPU-Bound的话,那么其实一个线程就可以搞定了(比如Redis6以下的版本),当然多核下这会带来浪费,如果想要利用多核,那么可能可以考虑启动多个线程,一个线程负责socket的accept事件,accept之后将client注册到其他线程维护的selector中处理后续的读写事件,tomcat,netty都是这个流程。或者说,涉及到IO的事件交给单独线程去处理,这个具体不清楚,Redis6.0以后就是利用了多核,好像就是这个流程。

  正因为Java线程的昂贵,驱动了Java的异步编程的繁荣,原生投递到线程池的任务会返回一个Future,这意味着你需要一直调用Future的相关方法去判断这个任务是否执行完成,Future#get本身是阻塞的,由于Future提供了一个hook方法(done方法),所以产生了一些诸如ListenableFuture的工具类,你可以传入一个callback,当任务执行结束会回调,callback也有弊端,一层嵌套一层代码会很丑陋。jdk8的completeablefuture很大程度解决了这个问题,引入了一套API,再到一系列reactor库,以上是工具层面的,而对于比如web层面,serverlt引入了异步servelt:为的就是将tomcat线程尽早的释放的出来,但是本质上可能还是需要另一个线程池去接手对应的请求的后续处理,以及spring5的webflux,默认直接使用netty作为http-server,以及结合了reacor库,以及各种与之而来的中间件reactor库。以上说起来就是为了利用少量的线程处理大量的操作,如果有一天JDBC那一层的reactor库开发出来了,那么可能就都搞通了,但是这个看起来并不是趋势,异步编程复杂,难以debug,各种弊端,所以Jdk团队一直在尝试开发协程库LOOM,现在只是一个early-access版本,可以简单的用了,具体原理,比如涉及到网络IO这一块,可以看下这篇文章:networking-io-with-virtual-threads。

  所以Go的并发做的其实比较吸引人,虽然也提供了lock那一套原语,但是Go崇尚的是channel:不要通过共享内存进行通信。 相反,通过通信共享内存。channel本身是并发安全的,其底层也是使用了lock,而Go的协程又是便宜的,所以Go可以做到这一点,协程调度那一层已经不需要我们考虑了,所以Go语言的关于并发模型的阐述很简单,甚至他都不推荐你去阅读,尽管不可避免的肯定也会有一系列happens-before原则,但是如果你只用channel,别使用锁之类的东西,那么没必要去阅读它的并发模型,更不用说协程池这类工具(虽然还是会有库使用协程池,但是大部分真的不需要),异步编程这些了。使用Go的并发,需要rethink concurrency。

  要类比的话channel其实就是Java里的阻塞队列,无buffer-channel = 容量为1的BlockingQueue,为什么Java不能像Go一样通过BlockingQeueu通信?因为Java里通信的对象都是线程,你被阻塞了那就是这个线程真的被操作系统挂起来了,并且线程也是昂贵的资源。所以哪一天Java有了协程,那么通过通信共享内存这一套自然也可以使用了。