转载

飞哥讲代码9:提升性能,线程数合适就行

案例

两周前,参与某一老产品的性能优化有如下收获:

  • 同事说,我配置了 1000个线程 ,但是总消耗时间还是需要 10分钟 左右,似乎没有真正的并发。
  • 经过分析代码,狂改一通代码,结果是:只配置了 32个线程 ,总消耗时间下降至 44秒

这个产品已有一定的年头,采用Java开发,但Maven配置的编译source/target还是 1.6(直接把配置修改为1.8整个产品编译会有问题。对于老产品,稳定优先,维护者并没有太多的动力升级到1.8,因为一升级需要对所有历史分支都升级并验证)。 为了线程安全,代码中大量地存在如下Double-Check写法(伪代码),无法享受Java高版本带来的红利,并不高效:

Description desc = cache.get(key);
if (desc == null) {
    synchronized(cache) {  // 这个是全局锁,极大影响并发
        if (desc == null) {  
            desc = getDescription(....);  // 此方法还会调用其它类似写法的Cache,主要逻辑是查询以及反射类,以及嵌套类,效率并不高
            cache = new CopyOnWriteMap(cache) // 对象Copy
            cache.add(key, desc);
        }
    }
}
return desc;

cache是自己实现的CopyOnWrite的Map,并没有使用ConcurrentHashMap,目的是提升读远远多于写的场景的性能。但这个性能问题就是写的时候。写的业务场景:

  • 业务中有76个Jar包,需要读取这个76个jar包中所有Class对象,每个Jar中平均约100个Class。
  • 采用懒加载方式,通过反射获取这些Class的方法与属性等描述信息,描述信息生成之后则放入Cache中。
  • 虽然采用多线程并发加载(每个Jar在一个线程中处理),配置1000个线程也是多余的。
  • 多个线程并没有完全并发处理,因为都是首次加载,线程阻塞在获取cache全局锁上。
  • 在整个流程中,上述采用Double-Check对Map操作的synchronized块有5~6个,即有5~6全局锁。

所做优化:

  • 把CopyOnWriteMap换成JDK自带的ConcurrentHashMap。
  • 去掉Double-Check + synchronized块,采用putIfAbsent来加入新值。

这次不再讲锁的优化(请参考 飞哥讲代码3:简洁高效的线程安全 ),而是来谈谈线程模型,并不是线程配置得越多,就能提升性能。

背后的知识

笔者在我司最早曾写过C/C++代码,接触的早期智能网平台产品是单线程多进程模式,即一个进程中只有一个线程在处理业务逻辑。所有的事件采用Select函数监视所有文件描述符的变化情况来处理各种请求。虽然只有一个线程在处理,性能并不差,因为线程并没有阻塞,它是满血在永不停息的干活。并发是采用多进程的方式,单个进程并绑定CPU,也避免了CPU在多进程之间的切换,效率更好。

开源项目大名鼎鼎Redis也是采用单线程模式,也类似于我们的智能网平台的机制,基于Reactor模式实现了多路 I/O 复用,由于Redis主要是对内存读写操作,单线程避免了上下文频繁切换问题,效率是出奇的高。

Java天生支持多线程,JDK也提供了多种机制来保证线程安全。JVM线程在Java 1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在Java 1.2中,线程模型替换为基于操作系统原生线程模型来实现。也就是说,现在的JVM中线程的本质,其实就是操作系统中的线程,Linux下是基于pthread库实现的多线程。

为了弄清楚Java的线程模型,先说几个概念:

  • 内核线程(Kernel-Level Thread, KLT) :由操作系统内核创建和撤销。一个内核线程由于I/O操作而阻塞,不会影响其它线程的运行。内核线程使用的资源就是内核栈和上下文切换时保存寄存器的空间。
  • 用户线程(User Thread,UT) :指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。
  • 轻量级进程(Light Weight Process, LWP) :是一种由内核支持的用户线程。它是基于内核线程的高级抽象,每一个进程有一个或多个LWPs,每个LWP由一个内核线程支持。在内核线程的支持下,LWP是独立的调度单元,就像普通的进程一样。

JVM中的线程在Linux下是基于pthread库实现,而pthread也存在演进:

  • LWP :内核2.6以前,pthread线程库基于LWP模拟线程,并采用的“一对一”的线程模型,即一个LWP对应一个线程。这个模型最大的好处是 线程调度由内核完成 了,而其他线程操作(同步、取消)等都是核外的线程库函数完成的,核外不需要额外的调度管理。
  • NPTL :内核2.6以后,POSIX标准对线程提出很多的改进,于是glibc中有了一种新的pthread线程库(NPTL:Native POSIX Threading Library),本质上来说,NPTL还是一个LWP的实现机制。

总结起来如下:

  • JVM一个线程对应一个用户线程,此用户线程由NPTL实现。
  • 由于NPTL是“一对一”的线程模型,则JVM一个线程对应内核中一个线程。
  • 一个CPU通过内核调度,分配给一个内核线程。
  • 映射关系:JVM线程(N) <–> 用户线程(N) <–> 内核线程(N) <–> CPU(1) 。

对于线程调度,通常有两种:

  • 协同式调度 :线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。线程执行时间系统不可控。
  • 抢占式调度 :每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定,线程的执行时间是系统可控。

Java采用的是抢占式调度的多线程系统,理解这个,就会明白为什么 Thread.yield() 可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法。

回到案例本身,为什么配置32个线程就可能解决问题,根据上面的线程模型,如果线程处理无阻塞,满血干活:

  • 初步可以得出的结论是:有多个CPU,就配置多少个线程。
  • 但实际并不那么简单,为什么Tomcat默认要500个处理线程?我们后面再讲。

M:N模型

Go语言天生为高并发而生,可以轻松构造上万的协程(goroutine)。它的并发模型采用MPG模型:

  • M(Mechine):代表着一个内核线程,也可以称为一个工作线程。
  • P(Processor):代表着处理器,它的主要用途就是用来执行goroutine的,一个P代表执行一个Go代码片段的基础(可以理解为上下文环境),它也维护了一个可运行的goroutine队列,和自由的goroutine队列,里面存储了所有需要它来执行的goroutine。
  • G(Goroutine):代表着goroutine实际的数据结构,即用户封装的要执行的方法。G维护goroutine需要的栈、程序计数器以及它所在的M等信息。
  • Seched:代表着一个调度器,它维护有存储空闲的M队列和空闲的P队列,可运行的G队列,自由的G队列以及调度器的一些状态信息等。

NPTL每个线程都对应内核中的一个调度实体,这种模型称为1:1模型(1个线程对应1个内核级线程)。而NGPL(Next Generation POSIX Threads)则打算实现M:N模型(M个线程对应N个内核级线程),也就是说若干个线程可能是在同一个执行实体上实现的。但在Linux上实现这个,要处理的细节问题非常之多,目前没有任何一个Linux实现了M:N模型。Solaris系统貌似实现M:N模型(待求证)。

Go语言而不依赖于Linux系统,而是在它的Runtime上实现MPG模型,本质即在用户态实现了NGPL。Goroutine只是一个内核线程执行的一个Task,只不过Go的Runtine能帮助你恢复上下文环境,维护了栈、程序计数器等信息,在Goroute中感觉就是像一个线程调用。轻松构造上万的Goroute,因为这不是真实的内核线程,而是线程执行的一个任务(Task)。

异步

如果线程不阻塞,则1:1的模型没有任何的问题,但实际上线程会阻塞在各种I/O操作中。如访问数据库,需要等待响应回来。为了增加并发,如果增加线程,每个线程对应一个内核线程,而内核线程是重资源型,过多的线程会导致内核调度上效率低下。

所以为了高效,采用线程复用。复用则需要当线程由于I/O阻塞时,可以释放出来,让它去干其它的活,当请求响应真正回来时,则通过回调通知。在1:1的模型的多线程多任务框架,通常异步采用回调方式。

函数回调有其缺陷,当遇到多重函数回调的嵌套,代码难以维护。对于多个回调组成的嵌套耦合,业界通常叫回调地狱(Callback Hell)。解决回调地狱的方案有不少,现在常见是链式调用,Java中实现链式调用有RxJava。RxJava中通过“流”来构建链式调用结构,“流”的创建、转化与消费都需要使用到它提供的各种类和丰富的操作符,也让使用成本大大增加。

在JVM之上的Kotlin语言,也实现了协程(Coroutine)框架,通过语法糖如async/await解决了普通多任务框架的回调地狱问题。async本质返回一个Deferred对象,在异步执行结束之后,调用await()方法通知等待者。等待者调用await()则先释放线程,再得到异步回调。

而Tomcat主要是Servlet容器,Servlet在3.0之前,API并不支持异步。同步导致的问题当有阻塞时,则线程是空闲的,为了并发,则需要更多的线程来处理,配置更多的线程也带了更多的成本,比如内存的增加,CPU对多线程上下问切换的性能损耗。

合理的线程线

线程个数设置多少合适?不是越多越好,多了竞争资源反而效率低。建议配置的线程数=可用的CPU核数/(1-阻塞系数)。阻塞系统在0到1之间,所谓阻塞系数就是发生的IO操作,如读文件,读socket流,读写数据库等占程序时间的比率。这个数值每个系统肯定不一样,可以先做个估计,然后测试逐步往最佳值靠拢。如果线程不是瓶颈所在,那么大概估一个值就好了。

再回到案例本身,通过优化锁的使用,减少了阻塞系数,当阻塞系数接近为0时,则配置的线程数=可用的CPU核数,而案例中的测试机器正好是32个CPU核。而案例中的IO操作主要是加载Class文件,Class文件是存在Jar文件中,所以以Jar为粒度并发读取是合适的,再多的并发也会由于文件锁导致更多的阻塞。

结语

提升性能,我们先有必要深入了解所使用语言的线程模型及其调度方式。提高并发,并不是一味的提高线程数,而是减少阻塞时间。为了提升线程的调度效率,通常是配置与CPU对等合理的线程线,通过异步框架合理地复用线程,让线程尽可能多的干活,而不是空闲在哪。

原文  http://lanlingzi.cn/post/technical/2020/0718_code/
正文到此结束
Loading...