只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

28、线程概述

内容来自王争 Java 编程之美

从这节课开始,我们进入多线程模块

尽管在平时的开发中,我们很少会直接编写多线程代码,但是常用的框架和容器,无一例外的都会用到多线程,比如 Dubbo、Tomcat 均使用多线程来处理请求
业务代码一般运行在这些框架或容器中,因此也就间接的会用到多线程,只有对多线程的使用和原理有透彻的了解,我们才能编写出线程安全且高性能的代码

在正式的学习如何编写多线程代码之前,我们先介绍多线程方面的一些基础理论知识,其中包括:线程的由来、调度策略、线程状态、线程模型、Java 线程的实现方式
这部分内容分两节讲解,本节讲解前三部分,下一节讲解后两部分

1、线程由来

进程和线程并非从计算机诞生就存在,随着计算机硬件的发展,操作系统经历了单进程、多进程、多线程,才逐渐发展为现在的并发模型
接下来,我们就按照演进的过程,介绍一下进程和线程的由来

1.1、单进程

进程是操作系统中非常重要的一个概念
进程实际上是对程序运行过程中所涉及的数据(比如创建的对象、变量)、代码、资源(比如打开的文件)、执行信息(比如执行到哪行代码了)的封装聚合,起到方便管理的作用
进程跟程序之间的关系,有点类似 "类跟对象" 之间关系,针对同一个类,JVM 可以创建多个对象,同理,针对同一个程序,操作系统也可以创建多个进程

早期的计算机只支持单进程,也就是一次只能执行一个程序,一个程序执行完之后,才会轮到下一个程序来执行,每个程序在执行的过程中,都独占计算机资源(比如 CPU、内存)
早期的计算机主要用于执行涉及科学计算的批处理程序,这类程序有两大特点

  • 其一:科学计算一般都是 CPU 密集型的,早期的计算机都是单核的,CPU 资源是瓶颈,并发执行多个程序并不能提高吞吐量(吞吐量指单位时间内执行完的程序个数)
  • 其二:对于批处理程序来说,我们一般只注重吞吐量,而不注重实时性,因此这种单进程的运行模式,对于早期计算机来说已经足够了

1.2、多进程

随着计算机的硬件的发展,特别是随着个人计算机的发展和推广,计算机的应用越来越丰富,不再只是用于工业级的科学计算
对于个人计算机来说,因其应用的特点(比如听歌的同时打字),人们对实时性的要求变的更高

因为限于计算机的发展,早期的计算机都是单核的,所以计算机内部采用 "并发" 的方式,来实现用户眼里的 "并行" 需求
也就是说,在粗时间粒度上,两个程序看似 "并行" 运行,在细时间粒度上,两个程序实则交替执行
这就类似我们观看的视频,每个视频由一组画面帧来组成,计算机每隔一段很小的时间,发送一个画面帧到屏幕,尽管帧与帧之间并非完全连续,但从人眼角度来说,我们是无法察觉到两个帧之间的细小间隔的

并行(parallelism)与并发(concurrency)的区别如下所示,从图中我们可以发现,两个程序并发执行,每个程序执行时间增加一倍,实际上也就相当于计算机变慢了一倍而已
image
从上图中我们还可以发现,一个进程在执行的过程中,会被频繁的暂停和重启继续执行
暂停时,操作系统需要帮忙记录下这个进程暂停时的环境信息,重启执行时,操作系统需要恢复这个进程执行的环境信息
我们把这里的环境信息叫做上下文(Context),两个进程之间切换执行,就会导致上下文切换(Context Switching)

我们拿打印资料来举例进一步解释

  • 打印机就相当于 CPU,打印机只管不停打印,具体打印什么它并不在乎
  • 打印员就相当于操作系统,负责告诉打印机打印什么内容
  • 假设现在有两个打印任务,一个是打印《数据结构与算法之美》,另一个是打印《设计模式之美》,边打印边递交给旁边的两个审查人员审查
  • 如果我们先打印一本,再打印另一本,那么任何时刻只有一个审查人员在忙碌,另一个审查人员就会闲着
  • 因此我们希望并发打印两本书,一本书打印 10 页之后,切换为打印另一本书,循环往复,交替执行
    假设《数据结构与算法之美》需要彩印,并且页面尺寸比较大,《设计模式之美》只需要黑白印书,并且页面尺寸比较小
    所以打印员将打印机从打印一本书切换为打印另一本书时,打印员首先需要将当前这本书的打印参数保存下来,以便稍后恢复打印
    这里的打印参数包括打印机设置(彩印还是黑白、页面尺寸等)、打印到的页码等

操作系统用进程表(Process Table)来记录所有进程的执行信息,进程表中每一个表项叫做进程控制块,简称 PCB(Process Control Block),一个 PCB 记录一个进程的执行信息,PCB 中包含的信息具体如下所示

  • 进程 ID:每个进程会有一个 ID
  • 进程状态:NEW、READY、RUNNING、WAITING、TERMINATED 等
  • 程序计数器(Program Counter),也叫做 PC 计数器:用来记录接下来要执行的代码所在的内存地址,实际上 PC 计数器中保存的值就是 CS 和 IP 这两个寄存器的值
  • 寄存器值:CPU 在执行过程中,会用到各种寄存器来暂存计算结果,因此在暂停进程时,操作系统需要将当下各个寄存器的值保存在 PCB 中,以便恢复执行时恢复寄存器的值
  • 调度信息:比如进程的优先级等
  • 文件列表:记录已经打开的文件的信息,比如读取到哪个文件的哪个位置了
  • 其他信息:比如统计信息,进程运行了多长时间了之类的

PCB 中的部分信息(比如寄存器的值),就相当于刚刚讲到的打印参数,CPU 在执行程序的过程中,寄存器起到重要的作用
操作系统将执行进程从进程 A 切换到进程 B 时,会将当前寄存器的值保存到进程 A 的 PCB 中,然后拿进程 B 的 PCB 中保存的寄存器值重设寄存器

1.3、多线程

实际上,多进程已经很好地满足了多个程序并发执行的需求,线程的引入完全是在设计、性能、易用性上的进一步优化

设计方面

在设计方面,引入线程之后,进程相当于做了拆分,拆分之后

  • 进程只负责线程共享资源的管理(比如虚拟内存中的代码段、数据段,以及打开的文件等)
  • 线程负责代码的执行,原来由进程负责的部分数据现在由线程负责,比如栈(也就是函数调用栈)、程序计数器、寄存器值

在引入线程前,操作系统按照 "进程" 来分配 CPU 执行时间,在引入 "线程" 后,操作系统按照 "线程" 来分配 CPU 执行时间
因此 "进程切换" 替代为了 "线程切换",当然,线程的切换跟进程切换一样,也会导致上下文的切换

需要注意的是,这里提到的 "进程切换" 跟 "进程交换"(Swap-in、Swap-out)没有任何关系
"进程交换" 指的是,操作系统将一个暂时不再运行的进程的所有内容保存到磁盘上(Swap-out),再将保存在磁盘上的某个进程的所有内容重新读入内存(Swap-in)

性能方面

在性能方面,随着多核计算机的发展,多线程可以让一个程序并行运行在多个 CPU 上,程序执行的效率更高
程序中的逻辑可以粗略的分为两类:需要 CPU 参与执行的逻辑和不需要 CPU 参与执行的逻辑(比如读写 IO)
实际上,提高程序执行效率(主要是吞吐量)的关键,是让两类逻辑 "并行" 执行(注意这里是并行而非并发),也就是在 IO 读写的同时,CPU 也在执行指令

如果操作系统只支持多进程,那么只是两个程序之间有可能 "并行" 执行,程序内部包含的两类逻辑之间并不能 "并行" 执行
在引入多线程之后,不仅程序间可以 "并行" 执行,程序内也可以 "并行" 执行,也就是说,多进程相当于粗粒度的 "并行",多线程相当于细粒度的 "并行"

易用性方面

在易用性方面,引入多线程之后,每个线程负责执行一个逻辑,多个逻辑之间的调度执行,由操作系统来完成
如果没有多线程,多个逻辑之间的调度执行,需要程序员自己去维护,引入多线程,程序的开发难度降低

比如前面讲到 java.nio 时,我们讲到过多种网络 I / O 模型,其中阻塞模型一般会配合多线程来实现,一个客户端连接对应一个线程,代码非常清晰
非阻塞模型不使用多线程,必须配合多路复用器来实现,多路复用器就相当于我们刚刚讲到的调度多个逻辑执行的代码
因为 Java 已经提供了现成的多路复用器(Selector 类),所以非阻塞模型实现起来好像也并不复杂,但这只不过是将复杂度隐藏了而已
如果没有现成的 Selector 类,那么我们就需要自己去编写相应的代码,代码就复杂多了

再比如,Java 虚拟机除了要执行应用程序的代码之外,还需要做 JIT 编译,还需要做垃圾回收,这几个任务都在独立的线程中完成
因为每个任务都是独立的,不应该将没有关联的业务代码拼凑在一起,分离开来更容易开发和维护

2、调度策略

对于支持多线程的操作系统,多个线程共同竞争 CPU 资源,那么操作系统就必须设计一定的算法,来调度多个线程轮流执行
基础的调度算法有很多种,比如:先来先服务、最短作业优先、高优先级优先、多优先级队列、轮转调度(Round Robin)等等
操作系统使用的调度算法一般会比较复杂,往往会组合各种基础调度算法,特别是针对多核计算机

大部分操作系统都会用到 "轮转调度" 这种基础的调度算法,基于此再组合其他基础的调度算法,以实现兼顾公平、优先级、响应时间、吞吐量等
这里我们重点讲一讲 "轮转调度算法",对于其他基础调度算法,以及具体操作系统的具体调度算法,如果感兴趣的话,你可以自行研究

"轮转调度算法" 的原理非常简单,所有就绪线程(状态为 READY,待会会讲到线程状态)会放入到一个队列中
操作系统每次从队首取一个线程,分配时间片执行此线程,当时间片用完之后,将这个线程暂停,放入队列的尾部,然后从队首再取新的线程继续执行,以此类推
当然,除了时间片用完之外,还存在其他情况也会导致线程切换,比如线程等待 I / O 读写完成、线程等待获取锁,以及线程主动让出时间片(比如调用 Java 中的 yield() 函数)

那么一个时间片到底有多长呢?
因为操作系统中的调度算法比较复杂,所以时间片的大小一般并不是简单固定的,不过时间片的大小一般处于 10ms ~ 100ms 这个量级范围,不能太大也不能太小

  • 时间片太大的话,其他线程等待的时间就会过长
  • 时间片太小的话,线程上下文切换耗时跟时间片相当(线程上下文切换的耗时在几 ms 的量级范围)
    CPU 还没执行几行代码就要进行线程切换,大部分时间都浪费在线程切换上了

image
实际上,线程切换的耗时并不只有上下文切换的耗时,还有线程调度的耗时(执行调度算法本身也耗时)
除此之外,线程切换还会导致 CPU 缓存失效(从执行一个代码切换为执行另一个代码),因此过于频繁的上下文切换,会导致程序执行变慢

操作系统一般会保证一个固定的时间间隔内,所有的就绪线程都要运行一遍,这样才能保证每个线程都不会等待太久
也就是说,如果线程数较多,那么时间片就相对短一些,如果线程数较少,那么时间片就会相对长一些
我们经常听说,对于 CPU 密集的程序来说(并不会有太多 IO 读写和 CPU 执行指令并行进行),程序中创建的线程过多会导致程序变慢
原因就是因为线程多导致时间片变短,上下文切换耗时占比增多,从而影响程序的执行效率

除此之外,我们还听到说,频繁加锁和释放锁也会导致程序变慢

  • 加锁和释放锁本身就耗时
  • 加锁会导致其它线程请求锁阻塞,在没有用完时间片的情况下,就切换为其他线程执行,执行代码逻辑的时间减少,大部分时间都浪费在了上下文切换,程序也会变慢

实际上,在线程没有发明之前,进程还承担着线程的作用(执行代码),当时的进程调度算法跟刚刚讲的线程调度算法基本一样
当线程发明之后,进程只负责资源的管理,线程承担了大部分进程的工作
所以线程在发明之初也叫做子进程(sub-process),或者轻量级进程(light-weight process),甚至在有些系统中,直接对进程的实现代码稍作修改,就实现了线程

3、线程状态

进程状态

在线程调度执行的过程中,线程会处于各种不同的状态,比如有时候在执行,有时候等待操作系统调度执行,有时候会等待 I / O 读写完成,有时候会等待获取锁等等
为了方便标明线程所处的状态,操作系统一般会定义如下线程状态

  • NEW:新创建的线程,在没有调用 start() 函数前,线程处于 NEW 状态
  • READY:线程一切就绪,等待操作系统调度
  • RUNNING:线程正在执行
  • WAITING
    线程在等待 I / O 读写完成、等待获取锁、等待时钟定时到期(调用 sleep() 函数)等等
    总之等待其他事件发生之后,线程才能被调度使用 CPU,此时线程的状态就是 WAITING 状态
  • TERMINATED
    线程终止状态,线程终止之后,未必就立即销毁
    有些操作系统为了节省线程创建的时间(毕竟要分配内存还得初始化一些变量),会复用处于 TERMINATED 状态的线程

当然,不同的操作系统可能会定义不同的多线程状态,不过基本上也不会差别很大
熟悉 Java 多线程的同学可能会发现,上述定义的线程状态跟 Java 线程状态有些差别,关于这点的解释,我们留在下一节中讲解

4、课后思考题

除了进程上下文切换、线程上下文切换、系统调度导致的内核态和用户态的上下文切换,你还能想到有哪些其他的上下文切换?
中断、操作系统 swap 也算是上下文切换

如果 N 个线程运行在 N 个 CPU,是否还会有线程切换?
会的,如果要固定线程所在的 CPU,那么我们需要使用 Java Thread Affinity

posted @   lidongdongdong~  阅读(52)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开