返回文章列表
JUC并发编程
JUC线程进程并发入门

01线程与进程、并发与并行

课程概览

并发编程系列的第一篇笔记。这里的“并发”一词涵盖了 Java 平台上的四个核心概念:

  • 进程
  • 线程
  • 并发
  • 并行

以及 Java 并发工具、并发问题与解决方案,同时也会涉及其他领域(如操作系统、Go 语言)中的并发思想。

学习本系列建议具备一定的 Java Web / JDBC 基础(线程安全问题只有在接触过 Web 服务器、分布式框架后才会真正遇到),基于 JDK 8,了解函数式编程与 lambda;示例工程统一使用 slf4j 打印日志、lombok 简化 Java Bean,并且会给每个线程起一个有意义的名字——这些都是好的实践。

本章的主线是:进程与线程的概念、并行与并发的概念、线程的基本应用(异步调用与提升效率)。

进程

程序由指令和数据组成,但指令要运行、数据要读写,就必须将指令加载至 CPU,数据加载至内存,运行过程中还可能用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。

  • 当一个程序被运行,操作系统从磁盘把这个程序的代码加载到内存,这时就开启了一个进程。
  • 进程可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士)。

线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,它把指令流中的一条条指令以一定的顺序交给 CPU 执行。
  • 在 Java 中,线程是操作系统调度的最小单位,进程是资源分配的最小单位。在 Windows 中进程是不活动的,只是作为线程的容器。

进程与线程对比

  • 进程基本上相互独立,而线程存在于进程之内,是进程的一个子集。
  • 进程拥有共享的资源(如内存空间等),供其内部的线程共享。
  • 进程间通信较为复杂:
    • 同一台计算机上的进程通信称为 IPC(Inter-Process Communication);
    • 不同计算机之间的进程通信,需要通过网络并遵守共同的协议,例如 HTTP。
  • 线程间通信相对简单,因为它们共享进程内的内存,典型例子是多个线程可以访问同一个共享变量。
  • 线程更轻量,线程上下文切换的成本一般要比进程上下文切换低。

并发与并行

单核 CPU:微观串行,宏观并发

单核 CPU 下,线程实际上还是串行执行的。操作系统中有一个组件叫做任务调度器,它把 CPU 的时间片(Windows 下时间片最小约为 15 毫秒)分配给不同的程序使用。由于 CPU 在线程间的切换非常快(时间片很短),人类的感觉是这些线程在“同时运行”。

总结为一句话:微观串行,宏观并行。一般把这种线程轮流使用 CPU 的做法称为并发(concurrent)。

单核 CPU:一个核心轮流调度多个线程(并发)

多核 CPU:真正并行

多核 CPU 下,每个核(core)都可以独立调度运行线程,这时候线程可以是真正并行(parallel)的。

多核 CPU:两个核心分别调度各自的线程(并行)

一句话区分并发与并行

Go 语言的创造者 Rob Pike 有一段经典描述:

  • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力;
  • 并行(parallel)是同一时间动手做(doing)多件事情的能力。

用家庭主妇做家务的例子来理解:

  1. 家庭主妇一个人做饭、打扫卫生、给孩子喂奶,轮流交替做这几件事,这时是并发;
  2. 主妇雇了一个保姆,她们一起做这些事,这时既有并发也有并行(还会产生竞争,例如锅只有一口,一个人用锅时另一个人就得等待);
  3. 雇了 3 个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行。

应用之异步调用

以调用方的角度来讲:

  • 需要等待结果返回才能继续运行,就是同步;
  • 不需要等待结果返回就能继续运行,就是异步。

多线程可以让方法的执行变为异步,不必眼巴巴地干等。比如读取磁盘文件时,假设读取操作要花费 5 秒钟,如果没有线程调度机制,这 5 秒内 CPU 什么都做不了,其他代码都得暂停。

案例:10 秒执行多个命令。 假设要连续执行 10 个外部命令,每个命令大约耗时 1 秒(例如等待网络响应)。如果在主线程中串行调用,总耗时就是 10 × 1 = 10 秒;把这些命令交给 10 个线程同时发起,等待期间 CPU 可以去做别的事,总耗时只相当于最慢的一个命令,约 1 秒出头:

public class AsyncCommand {
 
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();
 
        for (int i = 0; i < 10; i++) {
            int index = i;
            Thread t = new Thread(() -> {
                try {
                    // 单个外部命令约耗时 1 秒,这里用 ping 模拟一次等待
                    Process process = new ProcessBuilder(
                            "cmd", "/c", "ping", "-n", "2", "127.0.0.1").start();
                    process.waitFor();
                    System.out.println("命令 " + index + " 执行完成");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, "cmd-thread-" + i);
            threads.add(t);
        }
 
        // 串行执行:总耗时 ≈ 10 秒;多线程执行:总耗时 ≈ 1 秒
        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
 
        System.out.println("总耗时:" + (System.currentTimeMillis() - start) + " ms");
    }
}

类似的思路在实际项目中非常常见:

  • 视频文件转换格式等操作比较费时,可以开一个新线程处理视频转换,避免阻塞主线程;
  • Tomcat 的异步 Servlet 也是类似目的,让用户线程处理耗时较长的操作,避免阻塞 Tomcat 的工作线程;
  • UI 程序中开线程执行其他操作,避免阻塞 UI 线程导致界面卡顿。

应用之提升效率

多线程的另一大价值是充分利用多核 CPU 的优势,提高运行效率。

案例:并行计算后汇总。 想象下面的场景,执行 3 个计算,最后将结果汇总:

  • 计算 1 花费 10 ms
  • 计算 2 花费 11 ms
  • 计算 3 花费 9 ms
  • 汇总需要 1 ms

如果串行执行,总耗时是 10 + 11 + 9 + 1 = 31 ms。

如果是四核 CPU,分别用线程 1、线程 2、线程 3 执行三个计算,三个线程是并行的,总耗时只取决于最慢的那个线程,即 11 ms,再加上 1 ms 汇总,总共只花 12 ms。

注意:只有在多核 CPU 下并行计算才能提高效率,单核上仍然是轮流执行。

案例:文件下载统计。 需要下载并统计 10 个文件,每个文件的网络传输约 1 秒。串行执行时耗时逐个累加;多线程并发下载时,大家同时处于等待网络的状态,总耗时约等于其中最大的一个:

执行方式 10 个各约 1 秒的下载任务 CPU 状态
单线程串行 约 10 秒 等待 IO 期间 CPU 空闲
10 个线程并发 约 1 秒 等待期间 CPU 可调度其他线程

由此可以得到几点结论:

  1. 单核 CPU 下,多线程不能实际提高程序运行效率,只是为了能在不同任务之间切换,让不同线程轮流使用 CPU,不至于一个线程总占用 CPU,别的线程没法干活。
  2. 多核 CPU 可以并行跑多个线程,但能否提高效率要分情况:经过精心设计、可拆分的任务并行执行当然能提速,但不是所有计算任务都能拆分(参考后文的阿姆达尔定律),也不是所有任务都需要拆分,任务目的不同时谈拆分和效率没有意义。
  3. IO 操作不占用 CPU。一般拷贝文件使用的是阻塞 IO,这时线程虽然不用 CPU,却需要一直等待 IO 结束,没能充分利用线程——所以才有后面要学习的非阻塞 IO 和异步 IO 优化。