>> >> >> Reference << << << <<<<<<Ref>>>>>>
Concurrency & Parallelism
Modified: 2025-12-31 | Author:ljf12825

并发(Concurrency)与并行(Parallelism)

历史背景

单任务时代(1940s-1960s)

一台机器 = 一个程序

最早的计算机,没有操作系统,没有进程,没有并发

程序运行的方式:装纸带 -> 运行 -> 打印结果 -> 人工更换程序

问题

痛点:机器太贵,CPU太慢,人类时间浪费

批处理与多道程序(1960s-1970s)

并发的第一次觉醒

目标:让CPU不要空着

IBM, UNIX早期系统引入

P1 等待IO
-> CPU 切给 P2
-> P2 等待
-> 切给 P3

这就是早期的并发调度思想

关键突破

从这里开始:程序 =/ 进程,进程 = 可被调度的“活体”

分时系统(1970s)

并发变成“交互式需求”

UNIX, MULTICS出现多用户使用一台主机,每个人“感觉自己独占CPU”

引入

P1 10ms -> P2 10ms -> P3 10ms -> ...

并发从“提高利用率”变成了“提升用户体验”

进程模型的确立(1970s-1980s)

并发 = 隔离 + 调度

UNIX给了进程三个核心属性

维度含义
地址空间隔离
资源表文件、信号
执行上下文可调度

进程成为:资源的容器 + 调度的单位

但问题开始显现

线程诞生(1980s-1990s)

并发细化

服务器、GUI、数据库系统出现

于是:把“执行流”从进程中拆出来

线程模型诞生

Process
|-- Thread A
|-- Thread B
|__ Thread C

目标

新问题

并发从OS问题,变成了程序员问题

多核时代(2005-)

并行成为现实

频率墙(3~4GHz),单核提速结束,只能靠“核数”提升性能

并发不再是“时间片假象”,而是真实的物理同时执行

这使得

高并发互联网(2010s)

线程崩溃

Web服务器、游戏、IM系统,10万连接,每个连接一个线程 -> 内存炸、切换炸

线程模型出现天花板

问题原因
栈内存每线程1-8MB
切换内核态切换
共享地狱
阻塞放大效应

协程与用户态调度(2010s-)

并发抽象的第三次革命

目标:像写顺序代码一样写并发

做法

代表

系统模型
Gogoroutine + M:N
Pythonasyncio
Luacoroutine
C++20coroutine
游戏引擎Fiber + Job

进程

进程是计算机中正在执行的程序的实例。它是操作系统进行资源分配和调度的基本单位,也是程序在操作系统中的运行实体

定义

核心概念

进程是程序在操作系统中一次执行的实例

特性程序进程
本质静态的指令集合(如.exe文件)动态的执行实例
状态存储在磁盘上在内存中运行
生命周期永久存在有创建到终止的过程
资源占用不占用系统资源占用CPU、内存等资源

每个进程都有自己的资源和执行上下文

属性说明
程序代码(Text)可执行指令
数据区(Data)全局变量、静态变量
堆(Heap)动态分配内存(malloc/new)
栈(Stack)局部变量、函数调用信息
程序计数器(PC)下一条要执行的指令地址
寄存器状态CPU寄存器当前内容
进程ID(PID)唯一标识

进程的生命周期

进程从创建到终止大体经历五个状态

  1. 新建(New):进程正在被创建
  2. 就绪(Ready):进程准备运行,等待CPU调度
  3. 运行(Running):CPU正在执行该进程
  4. 阻塞/等待(Blocked/Waiting):进程等待某些事件(I/O,信号等)
  5. 终止(Terminated):进程执行完成或被杀死
New --> Ready --> Running --> Terminated
           |       ^
           v       |
         Waiting --^

进程的基本操作

进程创建与终止

子进程继承父进程的大部分资源,每个进程都有唯一PID

创建方式

  1. 系统初始化:操作系统启动时创建(init/systemd进程)
  2. 用户请求:用户运行程序(shell中输入命令)
  3. 进程派生:现有进程调用fork()创建子进程

Unix/Linux进程创建示例

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork(); // 创建子进程

    if (pid == 0) {
        // 子进程
        printf("Child Process PID: %d\n", getpid());
    } else if (pid > 0) {
        // 父进程
        printf("Parent Process PID: %d, Child PID: %d\n", getpid(), pid);
    } else {
        // fork失败
        perror("fork failed");
    }
    return 0;
}

进程终止原因

  1. 正常结束:执行exit()系统调用
  2. 异常结束:
    • 除零错误、越界访问
    • 收到信号(如SIGKILL, SIGSEGV
  3. 外部终止:被其他进程终止

进程调度(Process Scheduling)

进程调度,调度方式,决定了系统快不快,卡不卡,公平不公平\

CPU是稀缺资源,而进程很多

这个决策过程就是进程调度

调度发生时机

场景说明
进程创建新进程进入就绪队列
时间片用完时钟中断
进程阻塞等待IO
进程退出CPU空闲
更高优先级进程就绪抢占

相关概念

  1. 就绪队列(Ready Queue) 所有可以运行、但没拿到CPU的进程
  2. 时间片(Time Slice) 一个进程一次最多运行的时间,防止“霸占CPU”
  3. 抢占(Preemption) OS强制中断正在运行的进程,让更适合的进程上CPU
  4. 饥饿(Starvation) 进程因长期得不到所需资源而无法进展的状态

调度的目标

一个好的调度算法,要在多个目标之间权衡

目标含义
吞吐量单位时间完成多少进程
响应时间用户多久能看到反应
等待时间就绪队列中等多久
周转时间提交到完成的总时间
公平性不饿死任何进程

这些目标互相冲突

调度层次

长期调度(作业调度)
    v (选择哪些进程进入内存)
中级调度(内存调度)  
    v (内外存交换,挂起/唤醒)
短期调度(CPU调度)   <- 主要讨论这个
    v (毫秒级,决定下个运行的进程)

调度与进程状态切换

Running
  │  时间片到
  v
Ready
  ^
Blocked <- IO 等待

每次切换都伴随

上下文切换的开销

典型上下文切换步骤

  1. 保存当前进程的寄存器状态 -> 内存
  2. 更新PCB(进程控制块)
  3. 更新内存管理单元(MMU)寄存器
  4. 恢复下一个进程的寄存器
  5. 刷新TLB(转换检测缓冲区)
  6. 更新缓存

开销:几百到几千始终周期
现代CPU优化:快速上下文切换

经典调度算法

FCFS(先来先服务)

适合批处理系统

SJF/SJN(最短作业优先)

理论最优,但现实难用

SRTF(最短剩余时间优先)

SJF的抢占版本,新短任务到来可抢占当前进程
响应性更好,但实现更复杂

Round Robin, RR(时间片轮转)

原理

P1 -> P2 -> P3 -> P1 -> ...

优点:公平、响应快

缺点

交互式系统常用

Priority Scheduling(优先级调度)

多级调度队列算法(现代OS常用)

多级队列(MLQ)

队列之间通常不迁移 -> 不公平

多级反馈队列(MLFQ)

核心思想:用“行为”推测进程类型

规则简化版

  1. 新进程进入最高优先级
  2. 用完整时间片 -> 降级
  3. 主动让出CPU -> 升级

非常适合混合负载

现代Linux调度器(CFS)

设计思想:CFS仍有调度周期(sched period),但不再是固定时间片,而是根据权重动态分配运行时间

核心概念

概念含义
vruntime虚拟运行时间
红黑树按vruntime排序
nice值权重(优先级)

调度策略:选择vruntime最小的进程

CFS优缺点

公平、适合通用系统
实时性不强(另有RT调度器)

进程通信(IPC, Inter-Process Communication)

为什么需要IPC?

进程 = 独立的虚拟地址空间,一个进程不能直接访问另一个进程的内存
这保证了安全性和稳定性
代价就是:想协作,必须走操作系统提供的通道

IPC解决两类问题

现实中通信几乎一定伴随同步

IPC历史演进

IPC分类

两大模型

特点:内核中转、安全、简单、较慢

特点:极快,但必须自己做同步(锁/信号量)

按通信关系分类

graph TD A[IPC通信方式] --> B[直接通信] A --> C[间接通信] B --> B1[信号] B --> B2[Sockets] C --> C1[管道] C --> C2[消息队列] C --> C3[共享内存] C --> C4[文件]

按数据传输分类

类型示例特点
面向字节流管道、TCP Socket无消息边界,像流水
面向消息消息队列、UDP Socket有明确消息边界
面向共享共享内存直接读写内存

进程间需要通信来协作完成任务

IPC 机制

管道(Pipe)

ls | grep ".txt" # ls的输出作为grep的输入

原理:内核维护一块缓冲区,一端写,一端读,单向

int fd[2];
pipe(fd);

特点

命名管道(FIFO)

有名字的管道(文件系统中),无亲缘关系进程可通信

mkfifo mypipe

行为类似pipe

消息队列(Message Queue)

原理:内核维护消息链表,每条消息有:类型、长度、优先级

特点:

共享内存

原理

Process A
  [ shared physical memory ]
Process B

映射同一块物理内存

优点:零拷贝,极快

缺点:必须同步,极易出bug(竟态/死锁)

常见组合

共享内存 + 信号量 / 互斥锁

信号(Signal)

特点:不传数据,只传事件,异步中断

kill(pid, SIGTERM);

用途:通知,控制进程行为,回收子进程(SIGCHLD)

Socket

特点:可本地/可网络,面向字节流或数据报

类型用途
Unix Domain Socket本机高效IPC
TCP Socket网络通信
UDP Socket快速但不可靠

现代IPC

维度D-BusZeroMQgRPC
定位桌面 / 系统总线高性能消息库远程 RPC 框架
通信模型消息 + 总线消息模式请求 / 响应
是否依赖内核是(Unix Socket)否(用户态)否(TCP/HTTP2)
是否跨机器基本不可以天生支持
是否跨语言有限
是否有中心有(bus daemon)
复杂度
典型场景Linux 桌面 / 系统服务分布式组件微服务

D-Bus(桌面总线)

解决的问题

在Linux桌面/系统中

这些进程必须互相通知、调用、监听事件

而且要求

这就是D-Bus要解决的问题

核心设计思想:总线模型
┌─────────┐
│ Process │
└────┬────┘
┌────▼────┐
│  D-Bus  │  ← bus daemon(中心)
└────┬────┘
┌────▼────┐
│ Process │
└─────────┘
通信模式

D-Bus不只是“发消息”,而是三种

  1. Method Call(像RPC)
  2. Signal(广播事件)
  3. Property(可观察属性)
特点

它不是给业务系统用的,是给系统组件用的

gRPC(Google RPC)

解决问题

当系统变成:多语言、多团队、多机器、要长期维护
你需要:强类型接口、明确契约、自动生成代码、版本管理

gRPC的做法是:RPC + IDL + 工具链

核心架构
Client
  v
Stub(自动生成)
  v
HTTP/2
  v
Server
gRPC优势
gRPC代价

它是为“组织规模”服务的

ZeroMQ

解决的问题

传统IPC/Socket的痛点

ZeroMQ的目标:像用容器一样用通信

核心哲学:模式,而不是API

ZeroMQ不说“发”/“收”,而是

Publisher >>> Subscribers

你需要关心的是:想要什么通信关系,而不是socket + select + buffer + retry

特点

本质上是“通信虚拟机”

ZeroMQ的代价

ZeroMQ不是协议,它不保证:消息一定送达、不丢、顺序;你必须自己设计语义

自由但危险

进程间同步(IPC Synchronization)

进程间同步就是当多个进程访问共享资源(文件、共享内存、设备等)时,确保访问顺序正确、数据不冲突的技术

典型场景

为什么需要进程间同步

问题根源是共享资源的访问冲突

共享文件:file.txt
进程A写:100
进程B写:200
如果没有同步 -> 文件内容可能混乱或丢失

同步可以保证

进程间同步方法

信号量(Semaphore)

示例(POSIX信号量)

#include <semaphore.h>

sem_t sem;
sem_init(&sem, 1, 1); // 1 表示共享线程,初始值1

sem_wait(&sem); // 占用
// 访问共享资源
sem_post(&sem); // 释放
互斥锁(Mutex)
pthread_mutex_t mutex;
pthread_mutexattr_t attr;

pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&mutex, &attr);
条件变量(Condition Variable)
pthread_cond_t cond;
pthread_mutex_t mutex;

pthread_mutex_lock(&mutex);
while(!condition) {
    pthread_cond_wait(&cond, &mutex); // 等待条件满足
}
// 条件满足,继续执行
pthread_mutex_unlock(&mutex);
消息队列(Message Queue)
#include <mqueue.h>
mqd_t mq = mq_open("/myqueue", O_CREAT | O_RDWR, 0666, NULL);
mq_send(mq, "hello", 5, 0);
char buf[10];
mq_receive(mq, buf, 10, NULL);
共享内存 + 锁

进程间同步主要依赖操作系统提供机制,因为进程不共享内存,不能像线程那样直接用普通变量做锁

进程同步的注意事项

  1. 死锁
    • 两个或多个进程互相等待资源 -> 程序挂起
    • 解决:避免循环等待,设置超时
  2. 优先级反转
    • 高优先级进程等待低优先级进程释放资源
    • 解决:优先级继承机制
  3. 开销
    • 进程同步比线程同步慢
    • 因为需要操作系统内核介入

进程上下文(Context)

每次CPU切换进程时,会保存/恢复进程的上下文\

开销:

进程表(Process Table)

操作系统用来“统一管理所有PCB的数据结构”
操作系统需要

  1. 跟踪每个进程的状态
  2. 快速找到要调度的进程
  3. 管理进程资源
  4. 实现进程间通信
  5. 提供调试和监控信息

进程表就是这些信息的集中存储

进程表不描述单个进程,描述的是系统中所有进程

进程表
 ├── PCB (PID 1)
 ├── PCB (PID 2)
 ├── PCB (PID 3)
 └── PCB (PID N)

早期OS(教学模型),进程表就是一张表/数组

PCB process_table[MAX_PROCESS];

现代OS(如Linux),不存在一张统一的“进程表”,而是链表、红黑树、哈希表多个子结构组合
进程表是一个概念,不是一个具体结构体

Linux中的进程表

现代Linux没有“统一数组”,而是多个子数据结构组合

1. task_struct

2. 链表/红黑树/哈希表

Linux维护多种索引

数据结构用途
双向链表tasks遍历系统所有进程
红黑树pid_treePID查找
其他链表就绪队列、等待队列、僵尸队列

3. 调度器视角

task_struct字段

// Linux内核中的进程表实际上是task_struct的链表/树
// 位于:include/linux/sched.h

struct task_struct {
    // ========== 标识信息 ==========
    pid_t pid;                    // 进程ID
    pid_t tgid;                   // 线程组ID(主线程的PID)
    pid_t ppid;                   // 父进程ID
    
    // ========== 状态信息 ==========
    volatile long state;          // 进程状态
    /*
        TASK_RUNNING     (0) 正在运行或可运行
        TASK_INTERRUPTIBLE (1) 可中断睡眠
        TASK_UNINTERRUPTIBLE (2) 不可中断睡眠
        TASK_STOPPED     (4) 停止状态
        TASK_TRACED      (8) 被调试器跟踪
        EXIT_ZOMBIE     (16) 僵尸状态
        EXIT_DEAD       (32) 死亡状态
    */
    int exit_code;                // 退出代码
    int exit_signal;              // 导致退出的信号
    
    // ========== 调度信息 ==========
    int prio;                     // 动态优先级
    int static_prio;              // 静态优先级
    int normal_prio;              // 归一化优先级
    const struct sched_class *sched_class;  // 调度类
    struct sched_entity se;       // 调度实体
    struct sched_rt_entity rt;    // 实时调度实体
    unsigned int policy;          // 调度策略
    
    // ========== 内存管理 ==========
    struct mm_struct *mm;         // 内存描述符
    struct mm_struct *active_mm;  // 活动内存描述符
    
    // ========== 文件系统 ==========
    struct fs_struct *fs;         // 文件系统信息
    struct files_struct *files;   // 打开文件表
    
    // ========== 信号处理 ==========
    struct signal_struct *signal; // 信号处理
    struct sighand_struct *sighand; // 信号处理器
    sigset_t blocked;            // 阻塞的信号
    sigset_t real_blocked;       // 实际阻塞的信号
    
    // ========== 时间统计 ==========
    u64 utime;                   // 用户态CPU时间
    u64 stime;                   // 内核态CPU时间
    unsigned long nvcsw;         // 自愿上下文切换次数
    unsigned long nivcsw;        // 非自愿上下文切换次数
    
    // ========== 关系链 ==========
    struct task_struct *real_parent;  // 真实父进程
    struct task_struct *parent;       // 父进程(可能被ptrace替换)
    struct list_head children;        // 子进程链表
    struct list_head sibling;         // 兄弟进程链表
    
    // ========== 其他 ==========
    char comm[TASK_COMM_LEN];    // 可执行文件名
    unsigned int flags;          // 进程标志
    cpu_mask_t cpus_allowed;     // 允许运行的CPU掩码
    
    // ========== 链接结构 ==========
    struct list_head tasks;      // 所有进程链表节点
    struct hlist_node pid_chain; // PID哈希链表节点
};

Windows的EPROCESS

// Windows内核的进程表结构(部分)
typedef struct _EPROCESS {
    // ========== 对象头 ==========
    KPROCESS Pcb;                // 进程控制块
    EX_PUSH_LOCK ProcessLock;    // 进程锁
    
    // ========== 标识信息 ==========
    LIST_ENTRY ActiveProcessLinks; // 活动进程链表
    HANDLE UniqueProcessId;      // 进程ID
    HANDLE InheritedFromUniqueProcessId; // 父进程ID
    
    // ========== 内存管理 ==========
    PVOID SectionBaseAddress;    // 映像基地址
    PVOID VadRoot;               // 虚拟地址描述符树根
    SIZE_T VirtualSize;          // 虚拟大小
    
    // ========== 句柄表 ==========
    PHANDLE_TABLE ObjectTable;   // 对象句柄表
    
    // ========== 时间统计 ==========
    LARGE_INTEGER CreateTime;    // 创建时间
    LARGE_INTEGER ExitTime;      // 退出时间
    LARGE_INTEGER KernelTime;    // 内核时间
    LARGE_INTEGER UserTime;      // 用户时间
    
    // ========== 线程信息 ==========
    LIST_ENTRY ThreadListHead;   // 线程链表头
    ULONG ActiveThreads;         // 活动线程数
    
    // ========== 安全信息 ==========
    PACCESS_TOKEN Token;         // 访问令牌
    QUAD Vm;                     // 虚拟内存计数器
    
    // ========== 其他 ==========
    ULONG SessionId;             // 会话ID
    CHAR ImageFileName[16];      // 映像文件名
    ULONG PriorityClass;         // 优先级类
} EPROCESS, *PEPROCESS;

进程控制块(PCB)

PCB是操作系统管理进程的核心数据结构,每个进程对应一个PCB,操作系统通过PCB来管理和控制进程

字段作用
PID唯一标识
状态New/Ready/Running/Waiting/Terminated
程序计数器下一条指令地址
寄存器内容CPU状态
内存管理信息页表、段表
打开文件表进程打开的文件
信号量/IPC信息等待的事件或通信信息
struct ProcessControlBlock {
    // 1. 进程标识信息
    int pid; // 进程ID
    int ppid // 父进程ID

    // 2. 进程状态信息
    enum state { // 进程状态
        NEW, READY, RUNNING,
        WAITING, TERMINATED
    };

    // 3. CPU状态信息
    int program_counter; // 程序计数器
    int registers[16]; // 寄存器组
    // ... 其他CPU状态
};

进程的重要特性

  1. 并发性
    • 多个进程可以交替进行,给人同时运行的错觉
    • 单核CPU通过时间片轮转实现并发
  2. 独立性
    • 每个进程有独立的地址空间
    • 一个进程崩溃不会直接影响其他进程
    • 通过进程间通信(IPC)进行数据交换
  3. 异步性
    • 进程以不可预知的速度推进
    • 需要同步机制协调顺序

Unix-like 进程

这个概念在Windows中并不显著,Unix是类文件系统层次结构,所有东西都是进程,Windows则是独立对象模型

父子进程(Parent/Child Process)

在Unix/Linux中,进程只能同另一个进程创建

pid_t pid = fork();
fork 返回值进程
> 0父进程(返回子进程PID)
= 0子进程
< 0创建失败

父子进程几乎是完整拷贝

但它们是两个独立的进程

父子进程的关系

这也是为什么进程间通信需要IPC

特性父进程子进程
PID保持不变获得新的唯一PID
PPID自己的父进程父进程的PID
文件描述符共享,但独立维护复制父进程的文件表
未处理的信号不继承继承,但某些信号重置
资源统计独立计数从0开始计数(CPU时间等)
进程组通常相同通常相同(除非调用setpgid)

孤儿进程(Orphan Process)

父进程先退出,子进程还在继续,子进程就成了孤儿进程

孤儿进程不会出问题
内核会自动把孤儿进程的父进程设为PID 1
传统是init,现代是systemd,会负责回收它

孤儿进程不危险,是正常现象,很多后台程序就是主动“变孤儿”的

守护进程(Daemon Process)

守护进程是在后台运行、脱离终端、长期存在的进程

典型例子

守护进程的核心特征

特征说明
无控制终端不会因终端关闭而退出
长时间运行生命周期长
后台运行不与用户交互
通常是孤儿被init/systemd接管

守护进程的标准创建流程

标准守护化步骤

  1. fork,父进程退出
  2. 子进程 setsid(),脱离终端
  3. 再fork,防止重新获得终端
  4. 该工作目录/
  5. 重设文件权限掩码
  6. 关闭标准输入输出

僵尸进程(Zombie Process)

僵尸进程 = 已经死了,但还没被父进程“收尸”的进程

状态标记Z

为什么会有僵尸进程

这是Unix的设计选择,不是bug
父进程需要知道:子进程是否正常退出,退出码是多少

所以内核会保留子进程的PID + 退出状态,等待父进程调用

僵尸进程的问题

僵尸进程不占CPU,但占PID/PCB
大量僵尸进程 -> 进程表耗尽 -> 新进程创建失败

如何避免僵尸进程

父进程正确回收

wait(NULL);

捕获SIGCHLD

signal(SIGCHLD, SIG_IGN);

双fork(守护进程常用)

└─子
  └─孙(真正工作)

进程相关系统调用

进程相关系统调用
├─ 创建 / 装载
│   ├─ fork / vfork / clone
│   └─ exec*
├─ 终止 / 回收
│   ├─ exit / _exit
│   └─ wait / waitpid / waitid
├─ 身份 / 关系
│   ├─ getpid / getppid
│   ├─ setpgid / getsid
│   └─ setsid
├─ 调度 / 优先级
│   ├─ nice / setpriority
│   ├─ sched_yield
│   └─ sched_* 系列
├─ 信号
│   ├─ kill / tgkill
│   ├─ sigaction
│   └─ pause
├─ 资源限制 / 统计
│   ├─ getrlimit / setrlimit
│   ├─ times / getrusage
│   └─ prlimit
├─ 控制 / 管理
│   ├─ ptrace
│   ├─ prctl
│   └─ seccomp
└─ 命名空间 / 隔离(现代)
    ├─ unshare
    ├─ setns
    └─ clone (flags)

fork -> exec -> exit -> wait

fork()
功能

不同点

pid_t pid = fork();

if (pid < 0) {
    perror("fork failed");
} else if (pid == 0) {
    // 子进程
} else {
    //  父进程,pid = 子进程PID
}
fork的特性
exec()
功能

常用函数族

execl("/bin/ls", "ls", "-l", NULL);
execv("/bin/ls", argv);
execvp("ls", argv); // PATH 搜索
使用场景
父进程 fork()
    v
子进程 exec() 执行新程序

这样可以

  1. 父进程继续执行
  2. 子进程执行其他程序(shell命令、后台服务)
exit()
功能
exit(0); // 正常退出
exit(1); // 立即退出,绕过缓冲区
注意点
wait()/waitpid()
功能
int status;
pid_t child_pid = wait(&status);
if (WIFEXITED(status)) {
    printf("Child exited with %d\n", WEXITSTATUS(status));
}
常见问题
四者组合示意
Parent Process
┌─────────────┐
│ fork()      │  ----> Child Process
│             │        │ exec("/bin/ls")
│ wait()      │ <---- exit()
└─────────────┘
细节和工程实践
  1. fork多次 -> 可以生成进程树
  2. 双fork -> 守护进程模式(防止控制终端干扰)
  3. COW优化 -> 进程创建成本低
  4. wait/SIGCHLD -> 非阻塞回收子进程

线程

线程是操作系统能够进行运算调度的最小单位,是进程中的一个执行流程。一个进程可以包含多个线程,所有线程共享进程的资源(如内存空间、文件句柄),但每个线程拥有自己独立的执行栈和程序计数器

线程 vs 进程

特性进程线程
资源拥有独立的内存和资源共享进程的资源
通信方式IPC(管道、消息队列等)共享内存、更简单的通信
创建开销较大(需分配独立内存)较小(共享进程资源)
切换开销较大(需切换地址空间)较小(不切换地址空间)
独立性完全独立依赖于进程存在

线程是进程中的执行单元,是CPU调度的基本单位(现代OS)
进程负责而“资源隔离”(容器),线程负责“并发执行”(工作单位)

从CPU的角度看,CPU不关心进程,CPU实际调度的是执行上下文,在现代OS中,这个执行上下文就是线程。所以线程才是真正跑在CPU上的东西

Process
├── Thread 1  ← CPU 实际调度
├── Thread 2
├── Thread 3
└── 共享资源
    ├── 地址空间
    ├── 堆
    ├── 全局变量
    ├── 文件描述符

在Linux中

维度进程线程
内核对象task_structtask_struct
调度实体
地址空间独立 mm_struct共享 mm_struct
PID唯一共享 TGID
切换成本

在Linux中,线程和进程没有本质区别,都是task_struct;区别只是clone()时共享了什么

线程存在意义

如果只有进程,进程切换会造成

IPC成本高,创建成本高

线程的存在,使得

线程是在不牺牲隔离模型的前提下,把并发粒度做细

线程本质结构

线程 =/ 轻量进程,线程轻在资源上,不在调度上

一个线程拥有以下内容

线程生命周期

新建(New) → 就绪(Runnable) → 运行(Running) → 阻塞(Blocked) → 终止(Terminated)
          ^                    v
          |__ 等待(Waiting) ___|

线程创建与使用

创建

线程一定是内核创建的,但通常不是直接找内核要

代码
v
语言/运行时
v
系统调用
v
内核线程

操作系统层

这里是线程真正诞生的地方

Linux clone()

在Linux里,没有线程这个独立概念,本质上都是task_struct

创建线程时,内核进行以下流程

  1. 分配一个新的task_struct
  2. 分配线程栈
  3. 复制寄存器上下文
  4. 共享 or 不共享资源(关键!)
clone (
fn,
stack,
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD,
arg
);

这些flag决定是“新进程”还是“同进程线程”
线程只是“共享更多资源的进程”

用户态线程(绿色线程)

现代Linux/Windows是1:1线程模型(用户线程 = 内核线程)

Windows:CreateThread

Windows中,线程是一等公民,有独立的ETHREAD
创建时:分配TEB(线程环境块),分配用户栈,挂到进程里

语言层

对系统调用的封装

POSIX pthread_create
pthread_t tid;
pthread_create(&tid, nullptr, worker, arg);

背后

pthread_create
clone()
task_struct

你能控制的东西

C++ std::thread
std::thread t([] {
    do_work();
});
t.join();

本质:RAII包装,底层还是pthread/Win32

C#

早期:真Thread

new Thread(Work).Start();

问题:

现代:线程池 + 任务

Task.Run(() => Work());

这已经不是创建线程了

使用

创建线程的场景很少,使用线程的场景很多

正确的方式是:少量线程 + 大量任务

线程使用的四种经典模型

一线程一任务(过时)

简单,不可扩展

线程池(主流)
任务队列
v
N 个 worker创建
线程 + 事件循环
1~2 个线程
v
epoll / IOCP
v
回调
线程 + 协程(现代)
少量线程
v
成千上万个协程

注意事项

线程使用时,必须注意以下问题

  1. 生命周期
  2. 栈是私有的
    • 每个线程一条栈
    • 默认1-8MB
    • 栈溢出 = 直接崩
  3. 数据共享是危险的
  4. 同步原语
  5. 可见性 & 顺序
    • CPU重排序
    • 编译器重排序
    • memory order
  6. 线程不是越多越好
    • CPU 密集型:线程数量约等于核心数
    • IO 密集型:线程数量是核心数的2~4倍

线程是昂贵的执行资源,应该被“管理”,而不是被“随意创建”

线程通信与同步

通信但不同步 -> 数据错
同步但不通信 -> 无意义

通信

线程通信,共享内存是默认通道,同一进程内的线程,天然共享地址空间,这即是优势也是灾难源头

共享变量

struct Data {
    int a;
    int b;
};

Data g_data;

共享队列

Producer Thread
v
Queue
v
Consumer Thread

线程本地存储(TLS)

thread_local int counter;

同步

同步解决三个问题:

  1. 互斥(Mutual Exclusion)
  2. 顺序(Ordering)
  3. 可见性(Visibility)

互斥:一次只能一个进入

Mutex
std::mutex m;
m.lock();
x++
m.unlock();
Spinlock(自旋锁)

适合

顺序:谁先谁后

Condition Variable

经典模型:生产者 - 消费者

std::condition_variable cv;
std::mutex m;
bool ready = false;
信号量(Semaphore)

可见性:修改是否可见

问题来源:

bool done = false;

Thread A: done = true;
Thread B: while (!done) {}

可能死循环

解决方案

原子操作:不加锁同步atomic

std::atomic<int> x;
x.fetch_add(1);

使用场景

atomic只能保证单变量安全,不保证多变量一致性,跨多个变量仍然需要锁

通信 + 同步的经典组合模式

生产者-消费者

Queue + Mutex + ConditionVariable

读多写少

shared_mutex

工作窃取(Work Stealing)

每线程本地队列 + 偶尔偷

线程通信,本质是共享内存的设计问题
线程同步,本质是时间顺序与可见性的约束问题
最好的并发设计,是减少共享,而不是堆锁

并发问题70%是数据结构问题,30%才是锁的问题

线程安全与并发

TLS

伪共享(False Sharing)

多线程

同步

轻量线程

Coroutine(协程)

goroutine

Fiber(纤程)