Skip to content

Latest commit

 

History

History
315 lines (231 loc) · 13.8 KB

File metadata and controls

315 lines (231 loc) · 13.8 KB

用户线程、内核线程,以及 syscall 到底怎么发生的


第一部分:基础概念

一、线程

线程 = 一条独立的执行流,由内核调度。

每条线程独有:

  • 一份 CPU 寄存器状态(含 PC);
  • 一个独立的栈;
  • 一个调度优先级;
  • 一个 task_struct(Linux 内核里的线程控制块,PCB)。

同一进程内的线程共享:

  • 同一份地址空间(代码、堆、全局变量);
  • 同一份打开的文件表、socket。

线程切换只需换寄存器和栈指针,不换地址空间(不换 CR3),因此比进程切换轻。

二、用户态与内核态

x86 用 ring 0/ring 3 两个特权级区分:

用户态 (ring 3) 内核态 (ring 0)
执行的代码 用户进程代码 Linux 内核代码
权限 仅访问自己的虚拟地址空间,不能执行特权指令 可访问全部物理内存、执行特权指令、操作硬件
访问硬件的方式 通过 syscall 陷入内核 直接访问

第二部分:用户线程 vs 内核线程

三、本质区别:线程的代码住在哪一态

  • 用户线程:主要执行用户态代码,通过 syscall 短暂进入内核态;
  • 内核线程:代码完全位于内核地址空间,永不返回用户态,没有用户态上下文。

四、用户线程

由用户进程通过 pthread_create(底层 clone())创建。特征:

  • 属于某个用户态进程,有 PID/TID;
  • 大部分时间在用户态执行;
  • 通过 syscall 临时陷入内核态;
  • ps/top 里显示为可执行文件名。

五、内核线程

由内核通过 kthread_create() 创建,父进程统一是 kthreadd(PID 2)。

1. ps 中的样子

$ 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 的约定:表示该线程没有命令行参数(不从可执行文件启动),即内核线程。

2. 与用户线程的对照

维度 用户线程 内核线程
创建者 用户态程序 pthread_create / clone() 内核 kthread_create()
父进程 用户进程 kthreadd(PID 2)
执行态 主要用户态,偶尔内核态 始终内核态
用户态地址空间 (mm = NULL,只使用内核地址空间)
调用方式 通过 syscall 请求内核服务 直接调用内核函数
ps 显示 程序名 [xxx]
可被 kill
典型例子 nginxcurl 的工作线程 kworkerksoftirqdkswapdvhost-*

3. 为什么需要内核线程

某些工作没有用户进程会主动 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 的执行机制

一个常见疑问:用户线程调 syscall 时,是唤醒一个内核线程帮它服务,还是这条线程自己切到内核里跑?

答案:同一条线程自身切换特权级进入内核执行,完成后切回用户态。不创建新线程,不唤醒任何其他线程。

七、关键模型:一条线程横跨两态

每条用户线程在内核里都对应一份内核态执行环境:独立的内核栈、内核态寄存器保存区、task_struct

  • 用户态执行时:使用用户栈;
  • syscall 进入内核态后:切换到这条线程自己的内核栈,继续执行;
  • syscall 返回:切回用户栈。

始终是同一条线程,只是栈和特权级切换了。

八、Linux 的具体实现

1. 每条线程都自带内核栈

无论用户线程还是内核线程,创建时都会分配一个内核栈,典型大小 8 KB 或 16 KB:

            一条用户线程 T1 的资源
            
       ┌─────────────────────────┐
       │ 用户态                   │
       │   用户栈 (~8 MB)         │   跑用户代码时用
       │   用户态寄存器值          │
       └─────────────────────────┘
       ┌─────────────────────────┐
       │ 内核态                   │
       │   内核栈 (8 KB)           │   跑内核代码时用
       │   内核态寄存器保存区      │
       │   task_struct (PCB)      │
       └─────────────────────────┘

内核栈属于线程本身,不属于"内核线程"——任何线程进入内核态时都用自己的这块内核栈。

2. syscall 指令的硬件行为

以 x86-64 为例(libc read() 最终执行此指令):

用户态:
    mov rax, 0          ; sys_read 编号
    mov rdi, fd
    mov rsi, buf
    mov rdx, count
    syscall             ; 陷入内核
    ;; 内核返回后从这里继续
    test rax, rax

syscall 指令由 CPU 硬件完成以下动作:

  1. 特权级从 ring 3 切至 ring 0;
  2. RIP 跳转到 MSR LSTAR 寄存器指向的内核入口(entry_SYSCALL_64);
  3. 保存用户态 RIP、RFLAGS 到 RCX、R11;
  4. RSP 切换到当前线程的内核栈(通过 swapgs 加 per-CPU 数据获得栈基址)。

整个切换是单条指令完成,纳秒级,无线程调度参与。

3. 内核入口

// 简化伪代码
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 的内核栈。

4. 阻塞时的处理

若数据未就绪:

// sys_read 的某一步
if (数据未就绪) {
    set_current_state(TASK_INTERRUPTIBLE);
    schedule();                              // 主动让出 CPU
    // 被唤醒后从这里继续
}

行为:

  • 线程状态从 RUNNING 改为 SLEEPING,主动调 schedule() 让出 CPU;
  • 线程本身不消失,内核栈和寄存器现场保留在 task_struct 中;
  • 数据就绪时(通常由 I/O 中断触发),内核将其状态改回 RUNNABLE,放回就绪队列;
  • 被调度器选中后,从 schedule() 之后恢复执行,完成剩余 syscall 逻辑,返回用户态。

仍是同一条线程从头到尾,只是中间睡眠。

5. 时间片归属

时间片是分给线程的,不区分态。syscall 期间在内核态执行的 CPU 时间,全部计入调用者线程自己的时间片,没有独立"内核时间片"。

  • 时钟中断累加 current->se.vruntime,不区分 ring 3 / ring 0;
  • utime(用户态)与 stime(内核态)分别统计,但都归属同一线程;
  • 能否在内核态被时间片抢占取决于编译配置:
    • PREEMPT_NONE:仅在 syscall 即将返回用户态的检查点切换;
    • PREEMPT(完全抢占):任意位置都可被抢占,只要未持自旋锁、未禁用抢占、不在原子上下文;
  • 让出 CPU 有两条独立路径,均不创建新线程、现场保留:
    • 主动阻塞:等 I/O / 锁,schedule() 主动让出,状态变 SLEEPING,需事件唤醒;
    • 被动抢占:时间片耗尽或更高优先级线程就绪,时钟中断置 TIF_NEED_RESCHED,在最近抢占点切走,状态保持 RUNNABLE。

九、为什么不用"内核线程代为执行 syscall"

方案 A:线程自身切特权级(Linux 实际做法) 方案 B:唤醒内核线程代办
一条硬件指令完成切换,约 100 ns 需要唤醒、调度、IPC,微秒级
调用者直接同步等返回 需要把请求和结果在两条线程间传递
单核内完成,缓存友好 至少两次上下文切换
实现简单 同步与唤醒开销大

Linux/Windows/macOS 均采用方案 A。


第四部分:两者的关系

十、内核线程的真正用途:无 syscall 触发但必须执行的工作

发起者不是用户 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 实例

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 顺路携带执行;仅在无可借用上下文时才用内核线程。