线程 = 一条独立的执行流,由内核调度。
每条线程独有:
- 一份 CPU 寄存器状态(含 PC);
- 一个独立的栈;
- 一个调度优先级;
- 一个
task_struct(Linux 内核里的线程控制块,PCB)。
同一进程内的线程共享:
- 同一份地址空间(代码、堆、全局变量);
- 同一份打开的文件表、socket。
线程切换只需换寄存器和栈指针,不换地址空间(不换 CR3),因此比进程切换轻。
x86 用 ring 0/ring 3 两个特权级区分:
| 用户态 (ring 3) | 内核态 (ring 0) | |
|---|---|---|
| 执行的代码 | 用户进程代码 | Linux 内核代码 |
| 权限 | 仅访问自己的虚拟地址空间,不能执行特权指令 | 可访问全部物理内存、执行特权指令、操作硬件 |
| 访问硬件的方式 | 通过 syscall 陷入内核 | 直接访问 |
- 用户线程:主要执行用户态代码,通过 syscall 短暂进入内核态;
- 内核线程:代码完全位于内核地址空间,永不返回用户态,没有用户态上下文。
由用户进程通过 pthread_create(底层 clone())创建。特征:
- 属于某个用户态进程,有 PID/TID;
- 大部分时间在用户态执行;
- 通过 syscall 临时陷入内核态;
ps/top里显示为可执行文件名。
由内核通过 kthread_create() 创建,父进程统一是 kthreadd(PID 2)。
$ ps -ef | head
UID PID PPID ... CMD
root 1 0 ... /sbin/init
root 2 0 ... [kthreadd] ← 所有内核线程的父进程
root 3 2 ... [rcu_gp]
root 7 2 ... [kworker/0:0H]
root 11 2 ... [migration/0]
root 14 2 ... [ksoftirqd/0]方括号是 Linux 的约定:表示该线程没有命令行参数(不从可执行文件启动),即内核线程。
| 维度 | 用户线程 | 内核线程 |
|---|---|---|
| 创建者 | 用户态程序 pthread_create / clone() |
内核 kthread_create() |
| 父进程 | 用户进程 | kthreadd(PID 2) |
| 执行态 | 主要用户态,偶尔内核态 | 始终内核态 |
| 用户态地址空间 | 有 | 无(mm = NULL,只使用内核地址空间) |
| 调用方式 | 通过 syscall 请求内核服务 | 直接调用内核函数 |
ps 显示 |
程序名 | [xxx] |
可被 kill |
是 | 否 |
| 典型例子 | nginx、curl 的工作线程 |
kworker、ksoftirqd、kswapd、vhost-* |
某些工作没有用户进程会主动 syscall 触发,但内核必须执行,且需要可调度的上下文(可阻塞、可睡眠、可长期运行):
kswapd:内存水位低于阈值时后台异步换页;ksoftirqd:软中断负载过高时把溢出部分挪到进程上下文,避免饿死用户进程;kworker:通用 workqueue 的执行者,承接中断下半部等"不能在中断上下文做"的延迟工作;vhost-net:在内核态 poll virtqueue,直接处理 guest 的 virtio 网络请求。
$ ps -ef | grep '\['
root 2 ... [kthreadd] # 所有内核线程的父进程
root 3 ... [rcu_gp] # RCU 宽限期管理
root 14 ... [ksoftirqd/0] # 每 CPU 软中断处理
root 15 ... [migration/0] # 每 CPU 任务迁移(负载均衡)
root 32 ... [kcompactd0] # 每 NUMA 节点内存碎片整理
root 41 ... [khugepaged] # 透明大页合并
root 65 ... [kswapd0] # 每 NUMA 节点页面回收
root 80 ... [kworker/2:1] # 每 CPU 通用工作队列
root 95 ... [jbd2/sda1-8] # ext4 日志线程
root 120 ... [nfsd] # NFS 服务
root 200 ... [vhost-12345] # 为 PID=12345 的 QEMU 服务的 vhost 线程一个常见疑问:用户线程调 syscall 时,是唤醒一个内核线程帮它服务,还是这条线程自己切到内核里跑?
答案:同一条线程自身切换特权级进入内核执行,完成后切回用户态。不创建新线程,不唤醒任何其他线程。
每条用户线程在内核里都对应一份内核态执行环境:独立的内核栈、内核态寄存器保存区、task_struct。
- 用户态执行时:使用用户栈;
- syscall 进入内核态后:切换到这条线程自己的内核栈,继续执行;
- syscall 返回:切回用户栈。
始终是同一条线程,只是栈和特权级切换了。
无论用户线程还是内核线程,创建时都会分配一个内核栈,典型大小 8 KB 或 16 KB:
一条用户线程 T1 的资源
┌─────────────────────────┐
│ 用户态 │
│ 用户栈 (~8 MB) │ 跑用户代码时用
│ 用户态寄存器值 │
└─────────────────────────┘
┌─────────────────────────┐
│ 内核态 │
│ 内核栈 (8 KB) │ 跑内核代码时用
│ 内核态寄存器保存区 │
│ task_struct (PCB) │
└─────────────────────────┘
内核栈属于线程本身,不属于"内核线程"——任何线程进入内核态时都用自己的这块内核栈。
以 x86-64 为例(libc read() 最终执行此指令):
用户态:
mov rax, 0 ; sys_read 编号
mov rdi, fd
mov rsi, buf
mov rdx, count
syscall ; 陷入内核
;; 内核返回后从这里继续
test rax, rax
syscall 指令由 CPU 硬件完成以下动作:
- 特权级从 ring 3 切至 ring 0;
- RIP 跳转到 MSR
LSTAR寄存器指向的内核入口(entry_SYSCALL_64); - 保存用户态 RIP、RFLAGS 到 RCX、R11;
- RSP 切换到当前线程的内核栈(通过
swapgs加 per-CPU 数据获得栈基址)。
整个切换是单条指令完成,纳秒级,无线程调度参与。
// 简化伪代码
entry_SYSCALL_64:
保存用户态寄存器到内核栈;
fn = sys_call_table[rax]; // 查 syscall 表
ret = fn(rdi, rsi, rdx, ...); // 调用对应内核实现
rax = ret;
恢复用户态寄存器;
sysretq // 切回 ring 3,RIP 回到 syscall 下一条sys_read 等所有内核实现都在调用者线程的上下文中运行——current 指向的是 T1,使用的是 T1 的内核栈。
若数据未就绪:
// sys_read 的某一步
if (数据未就绪) {
set_current_state(TASK_INTERRUPTIBLE);
schedule(); // 主动让出 CPU
// 被唤醒后从这里继续
}行为:
- 线程状态从 RUNNING 改为 SLEEPING,主动调
schedule()让出 CPU; - 线程本身不消失,内核栈和寄存器现场保留在
task_struct中; - 数据就绪时(通常由 I/O 中断触发),内核将其状态改回 RUNNABLE,放回就绪队列;
- 被调度器选中后,从
schedule()之后恢复执行,完成剩余 syscall 逻辑,返回用户态。
仍是同一条线程从头到尾,只是中间睡眠。
时间片是分给线程的,不区分态。syscall 期间在内核态执行的 CPU 时间,全部计入调用者线程自己的时间片,没有独立"内核时间片"。
- 时钟中断累加
current->se.vruntime,不区分 ring 3 / ring 0; utime(用户态)与stime(内核态)分别统计,但都归属同一线程;- 能否在内核态被时间片抢占取决于编译配置:
PREEMPT_NONE:仅在 syscall 即将返回用户态的检查点切换;PREEMPT(完全抢占):任意位置都可被抢占,只要未持自旋锁、未禁用抢占、不在原子上下文;
- 让出 CPU 有两条独立路径,均不创建新线程、现场保留:
- 主动阻塞:等 I/O / 锁,
schedule()主动让出,状态变 SLEEPING,需事件唤醒; - 被动抢占:时间片耗尽或更高优先级线程就绪,时钟中断置
TIF_NEED_RESCHED,在最近抢占点切走,状态保持 RUNNABLE。
- 主动阻塞:等 I/O / 锁,
| 方案 A:线程自身切特权级(Linux 实际做法) | 方案 B:唤醒内核线程代办 |
|---|---|
| 一条硬件指令完成切换,约 100 ns | 需要唤醒、调度、IPC,微秒级 |
| 调用者直接同步等返回 | 需要把请求和结果在两条线程间传递 |
| 单核内完成,缓存友好 | 至少两次上下文切换 |
| 实现简单 | 同步与唤醒开销大 |
Linux/Windows/macOS 均采用方案 A。
发起者不是用户 syscall,而是定时器、硬件中断、内核自身代码:
kswapd:内核定时检查内存水位主动触发;ksoftirqd:软中断溢出后由 softirq 框架唤醒;vhost-net:由 KVM 通过 eventfd 唤醒;kworker:由其他内核代码queue_work()唤醒。
这些场景没有可借用的"用户线程的内核态执行流",故需专属内核线程。
| 场景 | 谁在执行 |
|---|---|
用户进程调 read() |
该用户线程自身,在其内核栈上执行 sys_read |
| 硬件中断到达 | 当前在 CPU 上的任意线程被打断,临时执行中断处理(借用上下文) |
| 内存压力换页 | kswapd 内核线程 |
| guest VM kick 通知发包 | vhost-net 内核线程 |
前两类无专属内核线程,内核代码借用现有线程的内核态执行;后两类需要专属内核线程。
一条用户线程 T1 处理一次 write() 的执行流
┌─────────────────────────────────────────────────────────────┐
│ 用户态 (ring 3) │
│ curl 执行 write(fd, buf, 1500) │
│ ────────────────────────────────────┐ │
│ │ syscall 指令 │
└───────────────────────────────────────│─────────────────────┘
│ 特权级切换 + 栈切换
┌───────────────────────────────────────▼─────────────────────┐
│ 内核态 (ring 0) │
│ T1 自己的内核栈上执行 sys_write │
│ → VFS → socket → TCP → 网卡驱动 → tap 队列 │
│ ────────────────────────────────────┐ │
│ │ sysretq │
└───────────────────────────────────────│─────────────────────┘
│ 特权级切回 + 栈切回
┌───────────────────────────────────────▼─────────────────────┐
│ 用户态 (ring 3) │
│ write() 返回 1500 │
└─────────────────────────────────────────────────────────────┘
自始至终是 T1 一条线程在执行,
中间仅切换了栈和特权级。
vhost-net 是 QEMU + KVM 配置下的 virtio 网络后端,综合体现两种线程模型:
guest 内的 curl(用户线程):
- 在 guest 用户态执行,调用
write(sock, ...); - 该线程通过 syscall 自身陷入 guest 内核态,执行协议栈与 virtio_net 驱动;
- 驱动写 virtio 寄存器(kick)被 KVM 截获;
- 全程无新线程被唤醒。
host 上的 vhost-net(内核线程):
- 启动 VM 时由
kthreadd创建,名为[vhost-<QEMU-pid>]; - 长驻 host 内核态,epoll 等待 KVM 投递的 kickfd;
- kickfd 触发后,直接读 virtqueue → 调用内核协议栈 → 写 tap 设备 → 通过 irqfd 注入虚拟中断;
- 因为完全在内核态执行,绕开用户态 QEMU,datapath 不发生用户态/内核态切换。
- 用户线程:有用户态地址空间,主要执行用户态代码,通过 syscall 临时进入内核态。
- 内核线程:无用户态地址空间(
mm = NULL),代码始终在内核态;由kthreadd创建,ps中以方括号标识;不可kill。 - syscall 的本质:同一条线程切换特权级和栈进入内核态执行内核代码,完成后切回用户态。不创建新线程,不唤醒其他线程。
- 内核线程的存在意义:执行无用户 syscall 触发但需可调度上下文的内核工作(后台回收、软中断分担、I/O 后端等)。
- 关系:多数内核代码由用户线程通过 syscall 顺路携带执行;仅在无可借用上下文时才用内核线程。