一, 了解进程, 线程模型
每次学习一个新技术, 我会先去了解这个技术的背景, 这个过程看似浪费时间, 其实在后续的学习过程中, 能够促进理解很多问题. 所以对于线程这个概念, 我会先从操作系统讲起. 因为操作系统的发展带来了软件层面的变革.
从多线程的发展来看, 可以操作系统的发展分为三个历史阶段:
真空管和穿孔卡片
晶体管和批处理系统
集成电路和多道程序设计
最早的计算机只能解决简单的数学运算问题, 比如正弦, 余弦等. 运行方式: 程序员首先把程序写到纸上, 然后穿孔成卡票, 再把卡片盒带入到专门的输入室. 输入室会有专门的操作员将卡片的程序输入到计算机上. 计算机运行完当前的任务以后, 把计算结果从打印机上进行输出, 操作员再把打印出来的结果送入到输出室, 程序员就可以从输出室取到结果. 然后, 操作员再继续从已经送入到输入室的卡片盒中读入另一个任务重复上述的步骤.
操作员在机房里面来回调度资源, 造成计算机存在大量的空闲状态 . 而当时的计算机是非常昂贵的, 人们为了减少这种资源的浪费. 就采用了 批处理系统来解决
批处理操作系统的运行方式: 在输入室收集全部的作业, 然后用一台比较便宜的计算机把它们读取到磁带上. 然后把磁带输入到计算机, 计算机通过读取磁带的指令来进行运算, 最后把结果输出磁带上. 批处理操作系统的好处在于, 计算机会一直处于运算状态, 合理的利用了计算机资源.(运行流程如下图所示)
(注: 此图来源于现代操作系统)
批处理操作系统虽然能够解决计算机的空闲问题, 但是当某一个作业因为等待磁盘或者其他 I/O 操作而暂停, 那 CPU 就只能阻塞直到该 I/O 完成, 对于 CPU 操作密集型的程序, I/O 操作相对较少, 因此浪费的时间也很少. 但是对于 I/O 操作较多的场景来说, CPU 的资源是属于严重浪费的.
多道程序设计的出现解决了这个问题, 就是把内存分为几个部分, 每一个部分放不同的程序. 当一个程序需要等待 I/O 操作完成时. 那么 CPU 可以切换执行内存中的另外一个程序. 如果内存中可以同时存放足够多的程序, 那 CPU 的利用率可以接近 100%.
在这个时候, 引入了第一个概念 - 进程, 进程的本质是一个正在执行的程序, 程序运行时系统会创建一个进程, 并且给每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰. 同时, 在 CPU 对进程做时间片的切换时, 保证进程切换过程中仍然要从进程切换之前运行的位置出开始执行. 所以进程通常还会包括程序计数器, 堆栈指针.
有了进程以后, 可以让操作系统从宏观层面实现多应用并发. 而并发的实现是通过 CPU 时间片不端切换执行的. 对于单核 CPU 来说, 在任意一个时刻只会有一个进程在被 CPU 调度
有了进程以后, 为什么还会出现线程呢?
在一个应用进程中, 会存在多个同时执行的任务, 如果其中一个任务被阻塞, 将会引起不依赖该任务的任务也被阻塞. 举个具体的例子来说, 我们平常用 Word 文档编辑内容的时候, 都会有一个自动保存的功能, 这个功能的作用是, 当计算机出现故障的情况下如果用户未保存文档, 则能够恢复到上一次自动保存的点. 假设 Word 的自动保存因为磁盘问题导致写入较慢, 势必会影响到用户的文档编辑功能, 直到磁盘写入完成用户才可编辑, 这种体验是很差的. 如果我们把一个进程中的多个任务通过线程的方式进行隔离, 那么按照前面提到的进程演进的理论来说, 在单核心 CPU 架构中可以通过 CPU 的时间片切换实现线程的调度充分利用 CPU 资源以达到最大的性能.
我们用了比较长的篇幅介绍了进程, 线程发展的历史. 总的来说是人们对于计算机的要求越来越高; 对于计算机本身的资源的利用率也在不断提高.
二, 线程的优势
前面分析了线程的发展历史, 这里简单总结一下线程有的优势如下
线程可以认为是轻量级的进程, 所以线程的创建, 销毁要比进程更快
从性能上考虑, 如果进程中存在大量的 I/O 处理, 通过多线程能够加快应用程序的执行速度(通过 CPU 时间片的快速切换).
由于线程是 CPU 的最小调度单元, 所以在多 CPU 架构中能够实现真正的并行执行. 每一个 CPU 可以调度一个线程
这里有两个概念很多人没有搞明白, 就是并行和并发
并行: 同时执行多个任务, 在多核心 CPU 架构中, 一个 CPU 核心运行一个线程, 那么 4 核心 CPU, 可以同时执行 4 个线程
并发: 同处理多个任务的能力, 通常我们会通过 TPS 或者 QPS 来表示某某系统支持的并发数是多少.
总的来说, 并行是并发的子集. 也就是说我们可以写一个拥有多线程并行的程序, 如果在没有多核心 CPU 来执行这些线程, 那就不能以并行的方式来运行程序中的多个线程. 所以并发程序可以是并行的, 也可以不是. Erlang 之父 Joe Armstrong 通过一张图型的方式来解释并发和并行的区别, 图片如下
三, 线程的生命周期
线程是存在生命周期的, 从线程的创建到销毁, 可能会经历 6 种不同的状态, 但是在一个时刻线程只能处于其中一种状态
NEW: 初始状态, 线程被创建时候的状态, 还没有调用 start 方法
RUNNABLE: 运行状态, 运行状态包含就绪和运行两种状态, 因为线程启动以后, 并不是立即执行, 而是需要通过调度去分配 CPU 时间片
BLOCKED: 阻塞状态, 当线程去访问一个加锁的方法时, 如果已经有其他线程获得锁, 那么当前线程会处于阻塞状态
WAITING: 等待状态, 设置线程进入等待状态等待其他线程做一些特定的动作进行触发
TIME_WAITING: 超时等待状态, 和 WAITING 状态的区别在于超时以后自动返回
TERMINATED: 终止状态, 线程执行完毕
下图整理了线程的状态变更过程及变更的操作, 每一个具体的操作原理, 我会在后续的文章中进行详细分析.
这里有一个问题大家可能搞不明白, BLOCKED 和 WAITING 这两个阻塞有什么区别?
BLOCKED 状态是指当前线程在等待一个获取锁的操作时的状态.
WAITING 是通过 Object.wait 或者 Thread.join,LockSupport.park 等操作实现的
BLOCKED 是被动的标记, 而 WAITING 是主动操作
如果说得再深入一点, 处于 WAITING 状态的线程, 被唤醒以后, 需要进入同步队列去竞争锁操作, 而在同步队列中, 如果已经有其他线程持有锁, 则线程会处于 BLOCKED 状态. 所以可以说 BLOCKED 状态是处于 WAITING 状态的线程重新唤醒的必经的状态
四, 线程的应用场景
线程的出现, 在多核心 CPU 架构下实现了真正意义上的并行执行. 也就是说, 一个进程内多个任务可以通过多线程并行执行来提高程序运行的性能. 那线程的使用场景有哪些呢?
执行后台任务, 在很多场景中, 可能会有一些定时的批量任务, 比如定时发送短信, 定时生成批量文件. 在这些场景中可以通过多线程的来执行
异步处理, 比如在用户注册成功以后给用户发送优惠券或者短信, 可以通过异步的方式来执行, 一方面提升主程序的执行性能; 另一方面可以解耦核心功能, 防止非核心功能对核心功能造成影响
分布式处理, 比如 fork/join, 将一个任务拆分成多个子任务分别执行
BIO 模型中的线程任务分发, 也是一种比较常见的使用场景, 一个请求对应一个线程
合理的利用多线程, 可以提升程序的吞吐量. 同时, 还可以通过增加 CPU 的核心数来提升程序的性能, 这就体现了伸缩性的特点
来源: http://developer.51cto.com/art/201908/600766.htm