并发编程的挑战和解决方案

并发系列的文章都是根据阅读《Java 并发编程的艺术》这本书总结而来,想更深入学习的同学可以自行购买此书进行学习。

并发编程的目的是为了让程序运行的更快,但是启动更多的线程不一定会让程序能够最大程度的并发执行,甚至有时候,并发比串行还要慢。在使用并发编程的时候,会面临许多挑战。

上下文切换

单核处理器也是可以支持多线程执行代码的。CPU通过分配时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片很短,所以 CPU 会通过不断切换线程执行,让我们感觉多个线程是同时执行的。

每次时间片切换之前都会保存上一个任务的状态,用来方便下次切换回这个任务的时候,可以再次加载这个任务的状态。这个保存到加载的过程就是一次上下文切换。

这样的切换是会影响多线程的执行效率的。想象我们看一本英文书籍,如果遇到单词不认识,我们会去查阅词典,但是在查阅之前,我们得先记住我们看到那一页了,以便等到查到单词后还能继续在之前看的位置读下去。虽然这样能够保证阅读的连贯性,但阅读的速度必然是受到影响的。

多线程并发执行不一定比串行执行快。测试发现,串行和并行做同一件循环操作,在一定达到循环次数之前,并发是没有串行速度快的。这正是因为线程的创建以及上下文切换有开销的缘故。

通过一些工具我们可以度量上下文带来的消耗:

  • 使用 Lmbench3 可以测量上下文切换的时长
  • 使用 vmstat 可以测量上下文切换的次数

那么如何减少上下文的切换呢?

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以可以用一线方法来避免使用锁。例如将数据的 ID 按 Hash 算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法。Java 的 Atomic 包使用 CAS 算法来更新数据,不需要加锁
  • 使用最少线程:避免创建不必要的线程,如果创建了很多多余的线程,将会造成大量的线程处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间切换。

死锁

锁是个非常有用的工具,使用也很简单易懂,但可能会引起死锁,从而导致系统不可用。在一些复杂的场景中,可能会遇到死锁问题,比如线程 t1 拿到锁之后,因为一些异常情况没有释放锁(例如死循环)。又或者是 t1 拿到了一个数据库锁,释放锁的时候抛出了异常,没有释放掉。

一旦出现死锁,业务是可以感知的,因为无法继续提供服务了,我们可以通过 dump 现场来查看哪个线程除了问题,并根据日志信息进行跟踪代码。

常见的几个避免死锁的方法:

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制。
  • 数据库锁的加锁解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

资源限制的挑战

资源限制可以分为计算机硬件资源软件资源

硬件资源限制有宽带速度、硬盘读写速度和 CPU 处理速度。如果宽带速度只有 2Mb/s,某个资源下载速度是 1Mb/s,但系统即使启动了 10 个线程下载资源,下载速度也不会编程 10Mb/s。

软件资源限制有数据库的连接数和 socket 连接等。

因为资源限制的原因,会导致有时候并发执行的任务因为资源不足,甚至还没有串行执行速度快。比如因为开了多线程导致了一些资源的请求,但资源又不够用,就会导致一些资源调度和上下文切换的开销。

对于硬件资源限制,可以使用集群并行执行程序。对于软件资源,可以考虑使用资源池将资源复用。在资源限制的情况下,要根据不同的资源限制调整程序的并发度。例如有数据库操作时,设计数据库连接数,如果 SQL 语句执行的非常快,但线程的数量要比数据库连接数大很多,那么某些线程将会被阻塞,等待数据库连接。

总结

并发编程有很多挑战,如果并发程序写的不严谨,出现了问题,定位和解决起来都比较棘手和耗时。所以对于 Java 开发工程师而言,建议多使用 JDK 并发包提供的并发容器和工具类来解决并发问题,这些类已经通过了充分的测试和优化,以上问题都是可以解决的。

    原文作者:SQL
    原文地址: https://juejin.im/entry/5927d441a22b9d00572839fc
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞