本课属于 第一阶段 · 走进操作系统(第 1~5 课)

上一课我们认识了进程——那个"正在运行的程序"。但你有没有想过一个问题:一个浏览器,怎么做到一边渲染网页、一边下载图片、一边响应你的点击?它好像会"分身术"一样。这一课的主角,就是进程里的这些"分身"——线程


从"浏览器一边……一边……"说起

你打开一个网页,盯着进度条看:图片在慢慢加载、文字已经先显示出来了、你还能随时滚动、点击链接。

如果浏览器进程是"一个人",它不可能同时做这么多事——除非它会分身。事实上,它确实会:一个进程内部,可以同时存在多条"执行流",每一条执行流,就是一条线程(Thread)。

一句话理解:进程是"一个干活的人",线程是这个人"同时伸出去的几只手"。几条线程在同一个进程里各干各的,但共享同一个进程的资源。

这一课,我们要搞懂线程是什么、它和进程的根本区别,以及一个几乎人人会说错的词——并发和并行。


5.1 线程是什么:进程里的"执行流"

回忆一下上一课的进程:它有自己的内存、自己的文件表、自己的一套资源。而线程,是进程内部的一条执行流

一个进程里可以有多条线程,它们:

  • 共享进程的资源(同一块内存、同一个文件表);
  • 各自有独立的栈独立的寄存器——也就是说,每条线程都记得"自己跑到哪了"。

还是拿浏览器举例,一个浏览器进程里可能同时有:

  • 一条线程负责渲染网页(把 HTML/CSS 画成屏幕上的画面);
  • 一条线程负责下载图片;
  • 一条线程负责响应你的点击、滚动。

它们共享同一个进程的内存(所以能方便地读写同一份页面数据),但各自跑各自的代码。


5.2 进程 vs 线程:核心就一句话

这是整个线程主题里最重要的一句话,值得你抄下来贴在显示器上:

进程是资源分配的单位,线程是 CPU 调度的单位。

什么意思?拆开看:

  • 进程负责"占资源":操作系统把内存、文件、网络连接这些资源,是以进程为单位来分配的。每个进程有独立的一亩三分地。
  • 线程负责"干活":真正被 CPU 调度、真正在 CPU 上跑的,其实是线程。CPU 分时间片,是分给线程的,不是分给进程的。

所以准确地说,我们上一课讲的"进程调度",更精确的说法其实是"调度进程里的线程"。一个进程至少要有一条线程(主线程),才能干活。

把两者放一起对比,差异一目了然:

维度进程线程
资源独立(各自的内存、文件表)共享(同进程内共享内存)
调度以进程为资源单位以线程为调度单位
创建/切换开销(要建独立地址空间)(共享地址空间,不用换)
隔离性(一个崩不牵连别的)(一个线程崩可能带垮整个进程)

最后一行是关键:线程之间因为共享内存,所以沟通方便、切换便宜;但也正因为共享,一个线程把共享数据写坏了,整个进程都可能跟着遭殃。这就是"隔离性弱"的意思,也是后面第 12 课要讲"竞态"的伏笔。


5.3 并发 vs 并行:两个天天被说错的词

这两个词,十个人里有八个分不清。一句话划清界限:

并发(Concurrency)是"看起来同时",并行(Parallelism)是"真正同时"。
  • 并发:单核 CPU 上,操作系统让你感觉边听歌边打字。真相是:CPU 在听歌程序和打字程序之间飞快地来回切换,切换速度快到你察觉不到,于是"看起来同时"。
  • 并行:多核 CPU 上,两个核心真的在同一时刻各自执行——这才叫"真正同时"。
一句话理解:单核是"一个人飞快地在两件事之间切来切去"(并发);多核是"两个人同时各干一件事"(并行)。

一个经典例子:单核电脑上,你边听歌边打字,是并发(快速切换);换了双核电脑,一个核放歌、一个核处理键盘,才是并行

注意一个常见误区:"并发"不等于"多线程",它俩是两个维度的事。并发说的是"任务能交错推进",多线程只是实现并发的一种手段。哪怕单线程,只要它用非阻塞的方式在多个任务间切换,也能"并发"。这点先记住,第 9 课讲协程时你会恍然大悟。

5.4 用户态线程 vs 内核态线程

线程还有个分类:由谁来管理

  • 内核态线程:由操作系统内核直接管理、调度。内核清楚地知道每条线程的存在。我们上一课说的"内核调度线程",指的就是这种。
  • 用户态线程(协程):由用户程序自己(通常是一个运行时库)管理,内核完全不知道它的存在。比如 Go 语言的 goroutine,就是用户态协程,由 Go 自己的运行时调度,再落到内核线程上去跑。

现代主流的做法,是两者的组合

内核管几个"内核线程"(真正在 CPU 上跑的),用户库再在它们上面调度一大堆轻量的"协程"。

这样既有了内核线程的并行能力,又有了协程"创建切换几乎零成本"的轻便。这个概念是第 9 课协程的直觉铺垫,你现在只需要记住:线程有"内核管的"和"用户自己管的"之分,现代系统常用"内核线程 + 用户协程"的组合。


动手实验:写第一个多线程程序

理论说完了,亲手写一个多线程程序,看看"交错执行"到底长什么样。

// lesson05.c —— 第一个多线程程序
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* worker(void* arg) {
    long id = (long)arg;
    for (int i = 0; i < 3; i++) {
        printf("线程 %ld 在干活,第 %d 次\n", id, i + 1);
        sleep(1);
    }
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, worker, (void*)1);  // 创建线程 1
    pthread_create(&t2, NULL, worker, (void*)2);  // 创建线程 2

    pthread_join(t1, NULL);   // 等线程 1 结束
    pthread_join(t2, NULL);   // 等线程 2 结束
    printf("两个线程都干完了\n");
    return 0;
}

编译运行(注意要多加 -lpthread,否则链接会报错):

gcc lesson05.c -o lesson05 -lpthread && ./lesson05

你大概会看到这样的输出:

线程 1 在干活,第 1 次
线程 2 在干活,第 1 次
线程 2 在干活,第 2 次
线程 1 在干活,第 2 次
线程 1 在干活,第 3 次
线程 2 在干活,第 3 次
两个线程都干完了

关键点来了:两个线程的输出是交错出现的,而不是"线程 1 干完 3 次,线程 2 再干 3 次"。这就是并发——两条执行流在 CPU 上你一段我一段地交替推进。

更有意思的是:多跑几次,顺序可能不一样。这次线程 1 先抢到输出,下次可能线程 2 先抢到。为什么?因为谁先跑、谁后跑,由操作系统调度器说了算,你(程序员)无法预知。这种"调度不确定",是并发编程里最核心、也最容易踩坑的特性,第 12 课会正式向它开炮。


深入点:两个埋给未来的"雷"

① 线程安全与竞态(先埋个雷)

注意我们这个实验里,两个线程各自打印自己的 id没有碰同一个变量,所以相安无事。但如果它们同时读写同一个共享变量,问题就来了:

int counter = 0;   // 共享变量
// 两个线程同时执行 counter++,可能两个都读到了同一个旧值,
// 各自 +1 再写回,结果只加了 1,而不是 2。

这就是竞态(race condition)。它出不出错,取决于线程之间的"运气"(谁先执行哪一步),所以极难复现、极难排查。第 12 课专门讲它,这里先记住这个雷的存在:多线程共享内存,同时写同一变量,会出事。

② 线程切换为什么比进程切换便宜

线程之间的切换,开销远小于进程。根本原因就一条:线程共享同一块地址空间,切换时不用切换地址空间(不用换内存映射表)。进程切换则要完整地换掉整套地址空间,成本高得多。

这也是为什么高并发场景下,人们更愿意用"多线程"或"协程",而不是狂开一堆进程——切得越便宜,越扛得住高并发


小结与思考题

这一课,我们钻进了进程内部,认识了线程:

  • 线程是进程内的执行流,共享进程资源,但有独立栈和寄存器;
  • 核心一句:进程是资源分配的单位,线程是 CPU 调度的单位
  • 并发是"看起来同时"(单核快速切换),并行是"真正同时"(多核各跑各的)
  • 线程分内核态和用户态,现代主流是"内核线程 + 用户态协程"的组合。

留三个问题:

  1. 用一句话说出「进程是资源单位,线程是调度单位」的含义。
  2. 单核 CPU 上能不能"并行"?那"并发"呢?
  3. 为什么多线程程序跑多次,输出顺序可能不一样?(提示:想想调度器。)
到这里,我们摸清了"进程"和"线程"这两个主角。从下一课开始,进入第二个阶段——系统编程入门。我们会先见一个贯穿整个 Linux 世界的哲学概念:"一切皆文件"。键盘、屏幕、硬盘、网络……在 Linux 眼里,统统是"文件"。而连接你程序和这些"文件"的,是一张小小的号码牌——文件描述符

标签: 计算机基础, 操作系统, Linux

添加新评论