这一章主要讲述线程的发展历史,以及并发编程带来的优势和挑战。

1. 线程的发展

1946年第一台计算机诞生,一直到20世纪50年代中期,这时候的计算机没有操作系统的概念,采用手工操作的方式工作,每次只能有一个人使用计算机。此时的手工操作方式,用户独占全机,昂贵的计算机资源得不到充分利用。

后来,随着计算机的发展,出现了批处理系统、多道程序系统,它们都提升了计算机的资源利用率。1961年,分时系统(Time Sharing System)出现,此时的一台计算机可以供多个用户终端同时连接并使用,就好像自己在独占计算机一样。分时系统将CPU的运行时间分成很短的时间片,按时间片分配给不同的连接终端使用,这充分利用了计算机资源,看起来就好像多个用户在同时使用计算机一样。

再后来,通用操作系统出现(General Operating System),它隐藏了硬件调用接口,为应用程序员提供调用硬件资源的更好、更简单、更清晰的系统调用接口,程序员就可以直接面向操作系统来编写程序,而不用关注硬件。

计算机的发展 [1],都在试图解决三个问题:

  • 资源利用率:尽可能的利用昂贵的计算机资源,如CPU、内存,而不是使其大部分时间处于等待状态

  • 公平性:不同的用户对计算机都有同等使用权,而不是排队等待

  • 便利性:可以同时执行多个程序,每个程序执行一个任务并在必要时相互通信

这三个问题促使进程和线程的出现。计算机可以同时运行多个程序,每个程序一个进程;而同一个进程中同样存在多个线程(轻量级进程),它们共享进程范围内的资源,如内存句柄和文件句柄,每个线程都有自己的程序计数器、局部变量等,而且同一个进程的多个线程都可以被调度到CPU上运行。但是,线程之间存在着共享资源(进程的内存),如果没有协同机制,那么多个线程在同时处理共享内存时会存在安全问题,比如一个线程读某个变量而一个线程同时在写这个变量,如果没有线程同步机制,则该变量的值变得不可预测。

举个例子,假设进程为多条高速公路,它们之间彼此独立,而线程就好比高速公路的多个车道,它们共享这条高速路的资源,如指示牌、测速设备等,线程中执行的任务就是一辆小汽车,它们相安无事的飞驰在自己的车道上,一旦它们开始抢夺道路资源,不遵守交通规则,超速、违规变道,那么后果可想而知。

2. 多线程的优势

Java是门支持并发编程的语言,充分利用其提供的并发特性来编写高并发程序,是信息化时代发展所需。多线程程序的优势可以从几个方面来说明:

  • 多线程程序能够极大的发挥处理器的强大能力。现代的计算机,CPU核心越来越多、处理频率越来越快、制造工艺越来越精,计算机的价格也越来越亲民,个人普通电脑都普遍是四核心、八核心甚至配置更高。在单核心CPU中,可能多线程程序不但没能提升处理性能,反而由于线程上下文切换的开销导致程序性能降低;但是,在多核心时代,多线程能够发挥处理器的强大能力,每个核心都能同时处理一个任务而不会造成上下文切换,如果还停留在单线程程序,这无疑是极大的浪费计算机资源,如果有100个CPU,那么单线程程序会造成99%的CPU都处于空闲状态。

  • 建模更简单。首先要明确,编写多线程程序与单线程程序相比,肯定是复杂且容易出错的,那么为什么说建模更简单呢?这需要从管理线程中执行的任务角度去分析。在单线程中执行多个任务,与多个线程每个线程仅执行一个任务相比,哪个更容易管理呢?很明显是后者。虽然多线程中的任务彼此可能存在通信,但是从模型上看,一个线程只管理一个任务,职责更单一。

  • 异步事件简化处理。这一点可以理解为编程模型的简化。单线程和多线程程序,在IO模型的选择上存在很大的不同。单线程程序,要提高并发请求数,底层需要使用异步IO、非阻塞IO或者IO多路复用模型 [2],但他们都是由操作系统底层支持,编程难度大;而多线程程序,由于应用层面本身采用了多线程模型,底层的IO则可以选择同步IO(异步的事情多线程做了),这就降低了开发难度。比如单线程模型中,Nginx采用多进程单线程和IO多路复用模型的架构,具备极高的并发处理能力;Redis采用单线程和IO多路复用模型,同样具备很高的处理效率。

  • 响应更灵敏的用户界面。Java的GUI框架AWT和Swing采用事件分发线程来替代传统的主事件循环,并可将将耗时的处理任务放到单独的线程中异步执行,从而提高用户界面的响应速度。

3. 多线程的挑战

多线程是一把双刃剑,带来诸多优势时也让开发者面临更大的挑战。

1、开发难度问题

由于多线程程序运行的不确定性,给开发难度带来很大的挑战。相对于单线程程序,多线程编程更难以掌握,不仅需要充分理解编程语言级的多线程支持,还需要掌握操作系统层级的多线程模型。尽管如此,多线程程序仍然难以调试和跟踪,尤其是产生 "活跃性" 问题时往往无法重现。

2、安全性问题

什么是安全性?安全性指的是程序永远不会返回错误的结果。看下边的示例:

public class UnsafeSequence {
    private int count;
    public int increment() {
        return count++;
    }
    public int getCount() {
        return count;
    }
}

多个线程调用 increment 方法,由于 count++ 包含了 读取、修改、写入三步内存操作,这三步不是一个原子操作,存在多个线程重复写同一值的情况,如下图示意:

1 1
Figure 1. 线程竞争导致错误的结果

由于线程在同时操作相同的count域,一个线程可能会覆盖另一个线程写入的值(线程竞争),最终count得到错误的值6,而不是预期的7.

线程安全本身是一个非常复杂的问题,由于线程之间存在资源共享,如果多个线程交替执行又没有有效的同步机制,执行结果将难以预料。尽管Java提供了足够的同步机制来保证线程安全性,但是这就要求开发者必须完全理解多线程程序的执行原理和Java的同步机制,这给程序开发带来很大的难度。

3、活跃性问题:

怎么理解活跃性?活跃性指的是如果线程存活则会保持运行,如果线程被销毁了当然就不活跃而是死亡了。说白了,如果线程还活着,那么它就能够 正确处理任务,即使它暂时被阻塞,但是如果后边它被唤醒后仍然能够继续运行。但是有时候,可能某些原因(比如程序bug),线程存活但是却始终得不到运行,或者运行着错误的任务,此时就产生了活跃性问题。最典型的活跃性问题就是 死锁,还包括 饥饿活锁

2.1 死锁

死锁指的是多个线程相互等待彼此而进入永久暂停状态。比如,线程 T1 持有锁 L1 去申请锁 L2,但是线程 T2 持有锁 L2 申请锁 L1,此时它们都在等待对象释放锁,从而进入永久阻塞状态。这就好比两个小朋友,他们各有一个玩具,但都不愿意分享给对方,却希望获得对方的玩具,最终互不相让,只能彼此干瞪眼了。

1 2
Figure 2. 死锁示意:小朋友争玩具,互不相让

死锁有一个著名的 哲学家就餐问题 (Dining philosophers problem),有兴趣的可以看这里

2.2 饥饿

饥饿(Starvation),指的是线程无法访问它需要的资源而不能继续运行。简单而言,饥饿是线程始终得不到运行时的状态,由于某些原因,线程始终得不到执行,所以产生饥饿现象。产生饥饿的原因很多,比如:

  1. 线程优先级设置不正确:CPU一直繁忙,但是线程被设置为低优先级,长时间得不到执行而变得"饥饿"

  2. 无限等待某一个资源:线程持有锁,内部由于某些原因造成了死循环,锁一直不能释放,那么其他需要逐个锁的线程就一直得不到执行而变得"饥饿"

2.3 活锁

活锁(Live Lock),指的是线程没有阻塞,仍然在运行,但是总在不断重复着某些错误的任务,也就是说,线程始终在做无用功。比如,程序编写了错误重试机制,而重试的次数被设置为无限次,一旦程序产生某些无法恢复的错误,那么线程就会一直重试着会出错的任务。

4、性能问题:

前边说了,多线程编程的目的是充分利用计算机资源、提高程序性能。但是,多线程同样存在性能问题,可能比单线程环境下性能还要低。你确定开100个线程就一定比1个线程快吗?答案是不确定。例如,多线程环境存在上下文切换(Context Switch)的开销,如果线程数量设置不当,不仅不能充分利用CPU资源,而且造成线程频繁地切换上下文,这需要保存和恢复线程当时的上下文信息,带来极大的性能开销;另一方面,线程之间必然存在数据共享,必须使用同步机制来串行化,此时会造成锁竞争,如果存在大量的锁竞争,势必会大大降低性能。

4. 掌握多线程

尽管多线程编程给我们带来了很大的挑战,但是其仍然是每个开发者都必须掌握的技术。信息时代高速发展的今天,计算机越来越普遍、性能越来越高,多线程技术是时代所需,而且线程无处不在。比如,在Java中,各类技术和框架都离不开多线程,从 JDBC 连接池分配和获取连接离不开线程,Servlet 每一个请求都开启一个线程来处理,Spring 中大量使用了线程池技术,RocketMQ 基于Java 多线程实现高并发等等。

过去,多线程编程是个 "高级话题",但是如今,多线程无处不在,每个开发者都必须掌握多线程编程,才能让我们成为信息时代的强者。


相关阅读