Linux 内核基础

模块1:内核基础(核心:内核是管家,应用是住户)

一句话总述:

内核 = Linux的超级管家 ,掌管CPU、内存、磁盘、网络所有硬件;

你的Web应用 = 普通住户,没有硬件权限,所有操作必须找管家(内核)帮忙。

1. 用户态 / 内核态

1. 基础角色定位

  1. 用户态(User Mode)

    普通应用程序的运行环境,权限受限 ,不能直接操作硬件、内核内存、进程调度等核心资源。

    你写的 C/Rust/Go 业务程序、普通可执行文件,全都跑在用户态。

  2. 内核态(Kernel Mode)

    操作系统内核的专属特权态,拥有最高硬件/系统权限,负责管理内存、进程、磁盘、网卡、中断等所有底层资源。

    用户态程序绝对不能直接执行内核代码

  3. libc / C标准库(clib)

    运行在用户态 的通用函数库,是应用程序 ↔ 操作系统内核 之间的中间封装层,也是绝大多数程序的默认桥梁。


2. 核心调用链路(最关键逻辑)

标准执行流程:

应用程序(用户态) → libc库函数(用户态) → 触发系统调用 → 切换到内核态(内核执行) → 结果返回用户态

简单拆解每一层作用:

  1. 应用层 :开发者只调用 printf/fread/malloc/socket 等常用接口,不用关心操作系统差异;
  2. libc层 (中间人)
    • 对原始系统调用做封装,简化使用;
    • 增加缓存、内存池、缓冲区,减少频繁态切换(优化性能);
    • 做跨系统兼容(Linux/BSD 系统调用号、接口不同,libc 统一抹平差异);
  3. 系统调用 :用户态进入内核态的唯一合法入口 ,通过 syscall/软中断等指令完成用户态 ↔ 内核态切换;
  4. 内核层:真正完成硬件/资源操作,执行完毕后切回用户态。

3. 经典案例(之前举例过的场景)

案例1:文件读写 fread / read
  • 你代码写 fread() → 这是 libc 函数(用户态)
  • libc 内部维护缓冲区,数据不足时,主动调用 Linux 原生 read 系统调用
  • CPU 切换到内核态,内核文件系统读取磁盘数据
  • 执行完毕,切回用户态,数据逐级返回应用
案例2:内存分配 malloc
  • malloc 属于 libc,大部分逻辑在用户态
  • 小块内存:libc 自己维护内存池,不进内核(避免开销)
  • 内存池耗尽:才调用 brk/mmap 系统调用,进入内核向操作系统申请堆内存

4. 重点细节

  1. 态切换有性能开销

    用户态 和 内核态 切换时,需要保存/恢复寄存器、切换页表, ==>成本不低<==。 这也是 libc 要做缓冲区、内存池的核心原因:少进内核 = 少切换 = 更快

  2. libc 不是内核

    libc 全程运行在用户空间,它只是"翻译+代理",本身没有硬件权限。

  3. 高级语言与 libc 的关系

    Rust、Go 等语言的标准库,在 Linux 下底层依然依赖 libc ;同时也支持绕过 libc,直接裸调用系统调用(嵌入式、高性能底层开发常用)。

  4. 边界区分

    • 直接写汇编调用系统调用:跳过 libc,应用直连内核;
    • 常规 C 程序:一定走 libc 过渡。

✅ Web开发关联:

Tokio/Axum的异步核心,就是减少用户态↔内核态的切换次数 , 因为 成本不低,切换越少,并发越高!

同理 Golang 重构运行时 的意义也是 减少 切换内核态的次数!


2. 虚拟内存 / 物理内存 / 页表

✅ 大白话比喻:

  • 物理内存:真实的内存条(比如16G,唯一的实物);
  • 虚拟内存 :内核给每个进程画的「专属假内存」,每个进程都觉得自己独占8G内存;
  • 页表 :内核的翻译小本本,把进程的「假地址」翻译成「真实物理地址」。

✅ 核心本质:

内核用虚拟内存隔离进程,A进程永远碰不到B进程的内存,安全又稳定。

✅ Web开发关联:

10个Web服务进程同时运行,互相不干扰内存,就是虚拟内存+页表的功劳。

✅ Rust代码演示(两个进程,变量地址相同,物理内存不同):

rust 复制代码
// 演示:每个进程有独立虚拟地址,打印同一个变量地址值一样
fn main() {
    let a = 1024;
    // 打印变量的虚拟内存地址
    println!("变量a的虚拟地址: {:p}", &a);
    println!("变量值: {}", a);
}

教学重点 :运行两次这个程序,输出的虚拟地址完全一样!但物理内存地址不同,内核通过页表翻译,这就是虚拟内存的魔法。


3. mmap(内存映射)

  • 文件硬盘:硬盘
  • 内核缓冲区: 内存条上,落在 内核 专属 虚拟地址 空间(所有进程共用一套内核地址),操作系统全局统一管理。
  • 用户态缓冲区: 内存条上,落在 进程用户态 虚拟地址 空间,是你 Rust/Go 代码里自己申请的数组、Vec 内存;

传统 read 两次拷贝:

磁盘 →【DMA 硬件搬运】→ 内核缓冲区 →【CPU 内存拷贝】→ 用户缓冲区

聪明的 计算机学者 发现, 内核缓冲区 →【CPU 内存拷贝】→ 用户缓冲区 这一步有点 脱裤子放屁的意思, 因为 都在 同一条内存条里, 没有必要 再 复制一次, 借助前面讲的 页表 技术, 可以将 核缓冲区 的地址 映射 到 用户缓冲区, 让进程 直接 使用就可以了, 所以 就有了 mmap 技术, 可以减少一次 IO。

mmap 一次拷贝:

【将 内核缓冲区 和 用户态缓冲区 做好 内存映射】→ 磁盘 → 【DMA 硬件搬运】→ 内核缓冲区 →【用户 进程 通过 内存映射 直接使用 位于 内核缓存区 的 数据】

✅ Web开发关联:

Nginx、Axum静态文件服务,全靠mmap!比普通read/write快几倍。

✅ Rust代码演示(用memmap2映射文件):

toml 复制代码
# Cargo.toml 依赖
[dependencies]
memmap2 = "0.9"
rust 复制代码
use memmap2::Mmap;
use std::fs::OpenOptions;

fn main() -> std::io::Result<()> {
    // 打开文件
    let file = OpenOptions::new().read(true).open("test.txt")?;
    // mmap:将文件映射到内存
    let mmap = unsafe { Mmap::map(&file)? };

    // 直接读内存 = 读文件内容
    println!("文件内容(mmap读取): {}", String::from_utf8_lossy(&mmap[..]));
    Ok(())
}

4. 进程调度 / task_struct / PID / FD

✅ 大白话比喻:

  • 进程调度 :班主任(内核)管理全班学生(进程),轮流用黑板(CPU);
  • task_struct :内核里每个进程的档案本,记录PID、内存、打开的文件、状态;
  • PID :进程的身份证号(唯一);
  • FD(文件描述符) :进程打开的「文件/网络连接」的门牌号(Linux一切皆文件:网络socket=FD,文件=FD)。

✅ Web开发关联:

你的Web服务启动后有PID,每一个HTTP客户端连接,都是一个FD!Tokio就是管理这些FD实现高并发。

✅ Rust代码演示(获取PID、打印FD):

rust 复制代码
use std::os::unix::io::AsRawFd;

fn main() {
    // 1. 获取当前进程PID(身份证)
    let pid = std::process::id();
    println!("当前进程PID: {}", pid);

    // 2. 获取标准输出的FD(门牌号)
    let stdout = std::io::stdout();
    println!("标准输出的文件描述符FD: {}", stdout.as_raw_fd());
}

5. VFS虚拟文件系统 / inode

✅ 大白话比喻:

  • VFS :内核的万能文件接口 !不管你用ext4硬盘、U盘、网络存储,应用看到的都是一样的open/read/write
  • inode :文件的身份证 !记录文件大小、权限、物理位置。文件名只是别名,inode才是唯一标识

✅ Web开发关联:

你用std::fs读文件,根本不用管文件存在哪个磁盘,VFS帮你适配,内核通过inode找到文件。

✅ Rust代码演示(获取文件inode):

rust 复制代码
use std::fs::metadata;

fn main() -> std::io::Result<()> {
    let meta = metadata("test.txt")?;
    // 获取文件inode号
    println!("文件inode: {}", meta.ino());
    Ok(())
}

6. 硬中断 & 软中断

场景设定

CPU = 驿站店长,正在专心整理货架(运行你的业务程序)

各种硬件设备 = 快递员、打印机、取件客户

1. 硬中断(硬件主动打断店长)

触发逻辑:外部硬件主动发紧急信号,强制打断当前工作

快递员骑着车到驿站门口,疯狂按门铃 (硬件电信号),不管店长现在在忙什么,必须立刻停手开门。

店长开门只做1秒极速操作

把快递全部扔进驿站临时筐里,在本子写一句「有快递待分拣」,马上关门,回去继续整理货架。

硬中断核心特点对应:
  • 触发源:外部硬件(快递员=网卡/键盘/硬盘)主动敲门,不受店长控制
  • 处理极短:只做标记、暂存数据,绝不做耗时活
  • 随机抢占:店长干活中途随时会被打断
  • 不能久留:门铃一直响,拖久了外面一堆快递员堵门(系统丢包、外设卡顿)

2. 软中断(延后处理耗时工作,内核内部执行)

触发逻辑:没有外部门铃,是店长自己看到本子标记,抽空处理

店长回到货架整理一会,手上活暂时告一段落,看到本子写着「快递待分拣」,才开始处理复杂工作:

拆快递、分楼栋、录入取件码、发短信通知客户,这一整套耗时流程,就是软中断

软中断核心特点对应:
  • 没有外部硬件信号,是硬中断留下的待办任务
  • 处理耗时业务:分拣、录入、发短信(对应解析数据包、IO回调、定时唤醒)
  • 不会抢占正在执行的硬中断,等硬件紧急信号处理完才执行

完整连贯流程(网卡收包真实映射)

  1. 网线数据包到达网卡 → 快递员上门按门铃 → 硬中断触发
  2. 店长(CPU)停下手上工作,开门把包裹丢进筐,记个待办标记,立刻关门回去干活(硬中断极速收尾)
  3. 等关门、无新快递敲门时,店长按本子标记分拣快递、通知客户 → 软中断执行完整业务逻辑

补充区分易错点

  1. 为什么分拣不能放开门的时候做?
    开门时门外还有一堆快递员等着(其他硬件中断),开门太久堵门,快递全堆积丢失;对应硬中断耗时过长会屏蔽其他外设中断,导致丢包、鼠标键盘卡顿。
  2. 你写的定时任务怎么套这个例子?
    墙上闹钟(硬件时钟芯片)每分钟叮一下门铃(时钟硬中断),店长记一句「5分钟后通知客户取件」;到点后店长空闲时执行通知操作,通知动作就是定时器软中断。

7. CPU的 使用率 & 负载

一句话总结关系:

  • CPU使用率:CPU忙不忙
  • CPU负载:有多少活等着CPU干

两者独立,不能互相推导,排查性能问题必须结合两个指标一起看。

一、两者分别是什么

1. CPU使用率(us、sy、id、iowait 这类指标)

定义 :统计一段时间内CPU真正在干活的时间占总时长的百分比 ,衡量CPU的忙碌程度。

计算逻辑使用率 = CPU执行任务的时间 / 统计总时间 × 100%

指标 CPU状态 核心场景 业务意义
%us 运行用户程序 代码循环、加密、运算 业务计算密集,程序本身耗CPU
%sy 运行内核逻辑 系统调用、进程切换、中断 系统底层开销大,锁竞争/频繁IO调用
%iowait 空闲但等IO 大量文件读写、数据库磁盘查询 IO瓶颈,磁盘/存储拖慢系统
%id 完全空闲 系统负载低,无任务 CPU算力富余,机器资源闲置
%us 用户态

应用程序自己的业务代码占用CPU,比如循环计算、业务逻辑、数值运算,运行在用户态进程上下文

%sy 内核态

程序发起系统调用、内核调度、软硬中断、内存管理等内核逻辑消耗CPU,运行在内核上下文(进程上下文/中断上下文)。

%id 空闲

CPU完全无事可做,没有就绪任务,处于空转休眠状态,这段时间不计入任何业务消耗。

%iowait
  1. iowait 属于空闲类时间,但和纯id空闲有本质区别:

    • id:系统完全没有待运行的进程;
    • iowait:有进程,但全部卡在IO阻塞,无计算任务可跑。
  2. iowait高的典型场景:磁盘慢、大量随机读写、数据库慢查询、大量文件读写。

  3. 计算关系:us + sy + iowait + id + irq + softirq + steal = 100%

额外补充top里配套的2个中断指标(和sy关联)
  • %irq 硬中断
    网卡、键盘、磁盘硬件触发硬中断时CPU消耗的时间,属于内核态硬件紧急处理。
  • %soft 软中断
    网络收包、定时器等延后处理软中断占用的CPU时间,同样属于内核范畴,统计在sy之外单独展示。

2. CPU负载(load average,uptime/top最前面3个数字)

定义 :统计CPU等待队列里的就绪进程总数,代表系统待处理任务的排队长度,反映任务拥堵程度。

  • 三个数值分别代表:1分钟、5分钟、15分钟平均排队任务数
  • 核心规则(多核CPU):
    4核机器,负载=4 → CPU刚好饱和,无排队;
    负载>4 → 进程排队等待CPU,出现拥堵;
    负载<4 → CPU有空闲算力。
  • 通俗例子:4个工位(4核CPU),门口排队6个人 → 负载≈6,2个人要等工位空出来。

模块2:进程

1. fork / exec 黄金搭档

1-1、核心前置结论:

  1. Linux 没有「直接创建一个新进程并运行程序」的系统调用
  2. 想运行一个新程序(比如 lsnginx、你的Go/Rust二进制),必须先 fork 克隆,再 exec 替换,这是Linux进程创建的唯一标准流程。
  3. 两个函数分工极端清晰:
    • fork造进程壳子(创建PID、复制进程环境,不换程序)
    • exec换进程灵魂(替换程序代码,不造新进程)

1-2、 fork()

fork 是Linux内核提供的唯一创建新进程 的系统调用。

它会克隆当前运行的进程(父进程) ,生成一个几乎完全一样的子进程

1-3、exec()

exec 不是一个函数,是一组系统调用(execl, execv, execve 等)。

它不创建新进程!不改变PID!

它的作用:把当前进程的所有程序内容(代码、数据、堆栈、虚拟内存)全部清空,替换成一个新的程序

  1. 调用 exec → 切换内核态;
  2. 内核废弃当前进程的旧页表
  3. 读取新程序的 ELF文件(二进制可执行文件);
  4. 把新程序的代码、数据加载到进程的虚拟内存空间;
  5. 创建新的页表,初始化堆栈;
  6. 切回用户态,直接从新程序的入口(main函数)开始执行

1-4、黄金组合:fork() + exec() 为什么必须一起用?

这是Linux的设计精髓,我用你在终端输入 ls 命令的真实流程讲透:

你打开终端 → 运行的是 bash 进程(PID=1234)

你输入 ls 回车,底层执行:

  1. bash 进程调用 fork()
    克隆出一个子进程(PID=5678),和bash完全一样;
  2. 子进程调用 exec()
    清空自己,替换为 /bin/ls 程序;
  3. 子进程运行 ls 命令,列出文件;
  4. ls 执行完毕,子进程退出
  5. 父进程 bash 继续等待,显示命令行提示符。

直接用 exec 会怎么样 ?

如果bash直接调用 exec ls

→ bash进程会被替换成ls,ls执行完,终端直接关闭!

→ 所以必须fork克隆一个子进程,让子进程去exec,父进程毫发无损。


✅ Nginx 示例:

Nginx主进程用fork生成子进程,主进程管理,子进程处理HTTP请求。

✅ Rust演示(用nix库调用fork):

toml 复制代码
# Cargo.toml
[dependencies]
nix = { version = "0.28", features = ["process", "unistd"] }
rust 复制代码
use nix::unistd::{fork, ForkResult};

fn main() {
    match unsafe { fork() } {
        Ok(ForkResult::Parent { child }) => println!("父进程,子进程PID: {}", child),
        Ok(ForkResult::Child) => println!("我是子进程!"),
        Err(_) => println!("fork失败"),
    }
}

2. 进程间通信

一句话总述:进程是独立房间,不能直接串门,内核提供串门工具

✅ 极简总结:

  1. 管道(匿名管道 Pipe):父子进程单向传话,仅亲缘进程可用
  2. 命名管道(FIFO):无血缘进程也能单向通信,文件作媒介
  3. 消息队列:内核维护消息盒子,多进程收发结构化消息
  4. 共享内存:最快IPC,多进程映射同一块物理内存,需手动加锁
  5. 信号量:专门做同步互斥,配合共享内存使用
  6. Socket:跨本机/跨机器通用,本地Unix Socket、网络TCP/UDP Socket
    Unix域套接字:本机最快进程通信(比TCP快,不用网络协议栈),Docker/K8s用它;

2-1. 匿名管道 Pipe(父子进程单向通信)

通俗理解

父亲开了一根单向水管 ,一头写、一头读;可以 父->子 和 子->父 的方向,让有亲缘进程用,数据读完就销毁,无法反复读。

特点:单向、仅亲缘、内存临时、字节流无边界。

Rust 代码示例(std::process + pipe)
rust 复制代码
use std::io::{Read, Write};
use std::process::{Command, Stdio};

fn main() {
    // 创建管道:子进程标准输出绑定管道写端,父进程持有读端
    let mut child = Command::new("echo")
        .arg("来自子进程管道消息:hello pipe")
        .stdout(Stdio::piped())
        .spawn()
        .expect("创建子进程失败");

    // 获取管道读句柄
    let mut pipe_reader = child.stdout.take().unwrap();
    let mut buf = String::new();
    pipe_reader.read_to_string(&mut buf).unwrap();

    println!("父进程从管道读到:{}", buf);
    // 等待子进程退出
    child.wait().unwrap();
}

运行输出:

复制代码
父进程从管道读到:来自子进程管道消息:hello pipe

2-2. 命名管道 FIFO(任意进程单向通信)

通俗理解

在磁盘建一个特殊管道文件 ,不占实际存储空间,任何进程都能打开读写;分两个终端/两个程序,一个写一个读。

特点:无亲缘限制、持久文件、单向字节流。

Rust 示例(依赖 nix 库操作FIFO)
Cargo.toml
toml 复制代码
[dependencies]
nix = "0.28"
写端程序 fifo_write.rs
rust 复制代码
use nix::unistd::{close, write};
use nix::sys::stat::{mkfifo, Mode};
use std::ffi::CString;
use std::os::unix::io::AsRawFd;
use std::fs::OpenOptions;

const FIFO_PATH: &str = "/tmp/my_fifo";

fn main() {
    // 创建命名管道文件,不存在才创建
    let c_path = CString::new(FIFO_PATH).unwrap();
    if mkfifo(&c_path, Mode::S_IRUSR | Mode::S_IWUSR).is_err() {
        eprintln!("管道已存在,跳过创建");
    }

    // 打开管道写端
    let file = OpenOptions::new()
        .write(true)
        .open(FIFO_PATH)
        .unwrap();
    let fd = file.as_rawFd();

    let msg = b"命名管道FIFO测试消息";
    write(fd, msg).unwrap();
    println!("写入FIFO完成");
    close(fd).unwrap();
}
读端程序 fifo_read.rs
rust 复制代码
use nix::unistd::{close, read};
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;

const FIFO_PATH: &str = "/tmp/my_fifo";

fn main() {
    let file = OpenOptions::new()
        .read(true)
        .open(FIFO_PATH)
        .unwrap();
    let fd = file.as_rawFd();
    let mut buf = [0u8; 128];

    let len = read(fd, &mut buf).unwrap();
    let msg = String::from_utf8_lossy(&buf[0..len]);
    println!("FIFO读到:{}", msg);
    close(fd).unwrap();
}

使用:先跑读端,新开终端跑写端,读端立刻收到消息。


2-3. 消息队列 MsgQueue(内核消息盒)

通俗理解

操作系统内核开辟一个全局消息信箱 ,所有进程都能投递/取出带类型标记的消息;消息会排队存储,就算进程退出消息还在,可异步收发。

特点:有消息边界、多进程并发、内核持久、支持消息分类筛选。

Rust 示例(nix 操作SystemV消息队列)

Cargo.toml 同上,依赖 nix

rust 复制代码
use nix::sys::msg::{msgget, msgsnd, msgrcv, MsgBuf, IPC_CREAT};
use nix::sys::ipc::IPC_RMID;
use std::ffi::c_void;

// 消息结构体
#[repr(C)]
struct Msg {
    mtype: i64, // 消息类型,用于筛选
    text: [u8; 64],
}

fn main() {
    // 获取/创建消息队列,key自定义
    let msq_id = msgget(0x1234, IPC_CREAT | 0o666).unwrap();

    // 1. 发送消息
    let mut send_buf = Msg {
        mtype: 1,
        text: *b"消息队列测试内容\0",
    };
    msgsnd(msq_id, &send_buf as *const Msg as *const c_void, 16, 0).unwrap();
    println!("消息投递成功");

    // 2. 接收类型=1的消息
    let mut recv_buf = Msg { mtype: 0, text: [0;64] };
    msgrcv(
        msq_id,
        &mut recv_buf as *mut Msg as *mut c_void,
        64,
        1, // 只取类型1消息
        0,
    ).unwrap();

    let text = String::from_utf8_lossy(&recv_buf.text);
    println!("读取消息:{}", text.trim_end_matches('\0'));

    // 删除消息队列
    nix::sys::msg::msgctl(msq_id, IPC_RMID, None).unwrap();
}

2-4. 共享内存 SharedMemory(最快IPC)

通俗理解

操作系统划出一块公共内存区域 ,多个进程都把这块内存映射到自己虚拟地址空间,直接读写,没有内核拷贝开销,速度第一。

致命问题:无同步机制,多进程同时写会数据错乱,必须搭配信号量/互斥锁使用。

Rust 示例(SystemV共享内存 + nix)
rust 复制代码
use nix::sys::shm::{shmget, shmat, shmdt, shmctl, IPC_CREAT};
use nix::sys::ipc::IPC_RMID;
use std::ffi::c_void;

const MEM_SIZE: usize = 128;

fn main() {
    // 创建共享内存段
    let shm_id = shmget(0x5678, MEM_SIZE, IPC_CREAT | 0o666).unwrap();
    // 映射到当前进程地址空间
    let shm_ptr = shmat(shm_id, None, 0).unwrap() as *mut u8;

    // 写入共享内存
    let data = b"共享内存高速通信";
    unsafe {
        for (i, &b) in data.iter().enumerate() {
            *shm_ptr.add(i) = b;
        }
    }

    // 另一个进程可读取,这里本地演示读
    unsafe {
        let mut buf = Vec::with_capacity(MEM_SIZE);
        for i in 0..data.len() {
            buf.push(*shm_ptr.add(i));
        }
        let s = String::from_utf8(buf).unwrap();
        println!("共享内存读取:{}", s);
    }

    // 解除映射
    unsafe { shmdt(shm_ptr as *const c_void).unwrap(); }
    // 删除共享内存
    shmctl(shm_id, IPC_RMID, None).unwrap();
}

2-5. 信号量 Semaphore(同步工具,不传输业务数据)

通俗理解

相当于银行叫号机计数器,专门控制多进程访问共享资源的顺序,解决共享内存并发冲突;不传递业务消息,只做互斥/同步。

  • 二元信号量(0/1)= 互斥锁,同一时间只允许1个进程操作共享内存
  • 计数信号量:允许N个进程同时访问资源
Rust 极简示例(二元信号量锁)
rust 复制代码
use nix::sys::sem::{semget, semop, SemBuf, IPC_CREAT};
use nix::sys::ipc::IPC_RMID;

fn main() {
    // 创建1个信号量集合
    let sem_id = semget(0x9999, 1, IPC_CREAT | 0o666).unwrap();

    // 初始化信号量值为1(可用,代表锁空闲)
    let init_op = SemBuf::new(0, 1, 0);
    semop(sem_id, &[init_op]).unwrap();

    // P操作:减1,获取锁(值为0则阻塞等待)
    let lock = SemBuf::new(0, -1, 0);
    semop(sem_id, &[lock]).unwrap();
    println!("拿到信号量锁,操作共享资源");

    // V操作:加1,释放锁
    let unlock = SemBuf::new(0, 1, 0);
    semop(sem_id, &[unlock]).unwrap();
    println!("释放信号量锁");

    // 销毁信号量
    nix::sys::sem::semctl(sem_id, 0, IPC_RMID, None).unwrap();
}

2-6. Unix域Socket(本地全双工IPC,最通用)

通俗理解

本地文件 形式的套接字,和网络TCP 逻辑一模一样,但不走网卡、只在内核转发;双向通信 ,无进程血缘限制,支持多客户端连接,本机IPC首选。

对比管道:管道单向,Socket双向;对比共享内存:自带流控,不用手动同步。

Rust 示例(Unix Socket 服务端+客户端)
Cargo.toml
toml 复制代码
[dependencies]
tokio = { version = "1.0", features = ["full"] }
server.rs(服务端)
rust 复制代码
use tokio::net::UnixListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    let sock_path = "/tmp/unix_ipc.sock";
    // 清理残留socket文件
    let _ = std::fs::remove_file(sock_path);
    let listener = UnixListener::bind(sock_path).unwrap();
    println!("Unix Socket服务启动,等待客户端连接");

    loop {
        let (mut stream, _addr) = listener.accept().await.unwrap();
        tokio::spawn(async move {
            let mut buf = [0; 128];
            let len = stream.read(&mut buf).await.unwrap();
            let msg = String::from_utf8_lossy(&buf[0..len]);
            println!("服务端收到:{}", msg);

            stream.write_all(b"服务端响应:Unix Socket双向通信成功").await.unwrap();
        });
    }
}
client.rs(客户端)
rust 复制代码
use tokio::net::UnixStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::main]
async fn main() {
    let mut stream = UnixStream::connect("/tmp/unix_ipc.sock").await.unwrap();
    stream.write_all(b"客户端消息:你好本地Socket").await.unwrap();

    let mut buf = [0; 128];
    let len = stream.read(&mut buf).await.unwrap();
    println!("客户端收到响应:{}", String::from_utf8_lossy(&buf[0..len]));
}

使用:先运行server,新开终端跑client,双向收发消息。


总结:6种IPC对比速记(Markdown表格)

IPC方式 通信方向 是否需亲缘进程 速度 适用场景
匿名管道 单向 必须父子 父子进程简单临时传输
FIFO命名管道 单向 无限制 任意本地程序简单单向传数据
消息队列 双向 无限制 结构化消息、异步离线收发
共享内存 双向直接读写 无限制 最快 大数据高速传输,必须加锁同步
信号量 无数据传输 无限制 - 配套共享内存做并发互斥
Unix Socket 全双工双向 无限制 本机多进程通用通信,替代管道

3. 状态

进程状态通俗理解(银行办业务类比)

  • 新建态:刚取完号还在填基础信息,还没真正进入等待大厅。操作系统正在给进程分配 PID 和 PCB。
  • 就绪态:坐在大厅里资料都齐了,就等叫号。CPU 一空闲就能立马上去运行。
  • 运行态:叫到号了,正在柜台办业务,占用着 CPU 执行指令。单核 CPU 同一时刻只能有一个进程在运行,多核可以并行跑多个。
  • 阻塞态:办到一半柜员说身份证复印件没带,得去旁边复印或者等家里人送来。这时候就算柜台空出来了也没法办,必须等复印好了才能重新回去排队,不能直接插队继续。
  • 终止态:业务办完了,或者因为异常被赶走了,进程结束,资源被回收。

模块3:IO

1、五大 IO 模型

核心前置:一次IO分为两个阶段

  1. 等待数据:硬件把数据读到内核缓冲区(网卡/磁盘准备数据)
  2. 拷贝数据:内核缓冲区 → 用户程序内存

1)阻塞IO(Blocking IO)

调用read/recv后线程全程卡死:

  • 阶段1(等数据):数据没到,线程休眠阻塞,什么都做不了;
  • 阶段2(拷贝数据):数据就绪后,线程继续阻塞,等内核把数据完整拷贝到用户空间;
  • 全程两阶段都阻塞,单个线程只能处理1个连接,并发差。

2)非阻塞IO(Non-Blocking IO)

文件句柄设置O_NONBLOCK标志,调用read不会卡死:

  • 阶段1(等数据):数据没准备好,内核直接返回EAGAIN错误,线程立刻返回,可以去处理别的业务;但业务代码必须循环轮询反复调用read询问"数据好了没",CPU空转浪费;
  • 阶段2(拷贝数据):一旦内核有数据,本次read会阻塞,直到拷贝完成;
  • 缺点:轮询占用大量CPU,极少单独使用。

3)IO多路复用(IO Multiplexing,select/poll/epoll)

一个线程通过select/epoll同时监控成千上百个文件连接:

  • 阶段1(等数据):线程调用多路复用函数阻塞等待,只要任意一个连接数据就绪,函数才返回;不用自己循环轮询所有fd;
  • 阶段2(拷贝数据):收到就绪通知后,再调用read读取数据,拷贝阶段依然阻塞
  • 优势:单线程管理海量连接,是后端服务最主流模型(nginx、redis、tokio底层)。

4)信号驱动IO(SIGIO)

给文件描述符注册信号回调:

  • 阶段1(等数据):线程完全不用阻塞/轮询,内核数据就绪后,主动给进程发送SIGIO信号,触发提前写好的回调函数;线程中间可以自由执行其他业务;
  • 阶段2(拷贝数据):回调内部调用read读取,拷贝阶段会阻塞;
  • 短板:信号处理机制复杂,多连接场景容易信号风暴,工程极少使用。

5)异步IO(AIO / libaio / io_uring)

真正全程不阻塞,两阶段全部交给内核完成:

  • 阶段1(等数据)、阶段2(拷贝数据):都由内核后台异步执行;
  • 用户线程发起read请求后直接返回,不用等待;内核完成「等数据+拷贝到用户内存」整套流程后,主动通知程序;
  • 区别于多路复用:多路复用只通知"数据准备好了",还要自己read拷贝;异步IO直接把数据拷好给你。

通俗一句话区分

  1. 阻塞IO:点奶茶,站柜台一动不动等做好、打包完才走;
  2. 非阻塞IO:隔30秒跑去柜台问一次"好了吗",没好就回去玩手机,反复跑;
  3. IO多路复用:
    你包下奶茶店前台窗口,坐在窗口边玩手机,一次性下单 100 杯奶茶,全程不能离开窗口。
    店员不会单独打电话,只要任意一杯奶茶做完,就拍你肩膀提醒:「12 号、37 号奶茶好了」。
    你放下手机,依次走到取餐台,一杯一杯亲手拿奶茶(read 拷贝,拿的时候不能玩手机),拿完继续坐回窗口等下一批通知。 核心:等待阶段你被固定束缚在窗口(epoll_wait阻塞),不能去做别的无关大事。
  4. 信号驱动IO:
    你下单 100 杯奶茶,留手机号,直接出门逛街、看电影,完全离开店铺。
    任意一杯奶茶做好,店员直接打你手机,电话铃声打断你看电影;
    你只能暂停电影,打车回店里,亲手取走做好的奶茶,取完再回去继续逛街。 核心:等待阶段你完全自由,在执行其他业务;通知是被动打断你当前工作,不是你专门蹲点等待。
  5. 异步IO:填地址让店员做好直接送货上门,全程不用你去柜台。

2、同步 & 异步

核心区别在于:调用方发起操作后,是否需要原地阻塞等待结果返回,才能执行后续逻辑

1. 同步调用

发起请求后,调用方必须原地干等,整个操作流程不结束、拿不到最终结果,就无法执行后面任何代码。

生活化类比:去银行柜台办转账业务,你站在窗口不能离开,柜员录入信息、核验、扣款全部完成,把回执交给你之后,你才能走掉去做别的事。

代码特征:函数调用返回前,线程全程阻塞;所有同步IO模型(阻塞IO、IO多路复用、非阻塞轮询、信号驱动IO)都属于同步体系。

2. 异步调用

发起请求后函数立刻返回,调用方线程可以继续执行其他业务逻辑;当底层操作100%全部完成后,内核/服务会通过回调函数、事件通知、Future/Promise、信号 等机制主动推送结果给调用方。

生活化类比:手机点外卖,下单支付完页面立刻关闭,你马上回去写代码、刷视频;骑手取餐、配送全部完成送到楼下后,主动打电话通知你取餐。

代码特征:等待数据 + 内核拷贝数据两个阶段均由内核后台完成,全程不阻塞用户线程,典型代表是Linux io_uring、Rust tokio完全异步IO、Java CompletableFuture。


3、阻塞 & 非阻塞

阻塞 和 非阻塞 描述的是调用方在等待数据就绪阶段的线程状态:线程是原地干等、被操作系统挂起放弃CPU,还是调用后立刻返回、可以先去执行其他业务。

1. 阻塞

发起IO请求后线程直接被操作系统挂起,彻底失去CPU时间片,什么业务都无法执行;必须等到硬件数据完全就绪,才会唤醒线程继续往下执行。

举例:调用普通read读取网络数据,远端数据还没到达内核缓冲区时,线程直接卡住休眠,CPU不会分配任何时间片给该线程,期间无法执行任何代码。

生活化类比:你去快递站取件,快递还没分拣出来,工作人员让你站在窗口原地等待,不能离开去买水、玩手机,全程不能做别的事。

2. 非阻塞

发起IO请求后函数会立刻返回,无论内核缓冲区有没有准备好数据:

  • 如果数据还没就绪:返回EAGAIN/EWOULDBLOCK错误码,告知当前无数据;
  • 如果数据已经就绪:直接读取并返回有效数据。

调用方拿到返回结果后,线程不会被挂起、不会丢失CPU使用权,可以先去处理其他业务逻辑,空闲时再循环调用read轮询询问内核"数据是否就绪"。

生活化类比:快递站有自助查询机,你去查件,如果包裹还没分拣完成,机器立刻提示"暂无包裹",你可以先去超市、买奶茶,过几分钟再来查询一次。

组合 说明 典型场景
同步阻塞 调用read无数据就挂起线程,全程等待 普通socket、文件默认读写
同步非阻塞 read立刻返回,业务循环轮询查询数据 O_NONBLOCK 单fd轮询
同步非阻塞多路复用 epoll_wait阻塞等待就绪通知,再同步read Nginx、Redis底层
异步非阻塞 内核完成等待+拷贝后主动通知,全程不阻塞 io_uring、libaio

Web开发者最大短板 :你们只用Tokio封装好的异步,不懂底层epoll!

一句话总述:IO = 应用等内核读数据(网络/磁盘),等的方式分4种

1. 阻塞IO

✅ 比喻:奶茶店点单,站在柜台死等 ,啥也不干;

✅ 缺点:一个连接占一个线程,并发极低。

2. 非阻塞IO

✅ 比喻:点单后每隔10秒问一次 ,不用死等,但一直问很累;

✅ 缺点:循环询问消耗CPU。

3. epoll(神!Web高并发基石)

✅ 比喻:把所有订单交给店员,店员做好了主动喊你 ,你不用死等、不用反复问;

✅ 核心本质:Linux IO多路复用,事件驱动

✅ Web关联:Nginx、Tokio、Axum、Redis 全靠epoll,支撑百万并发连接

4. io_uring

✅ 新一代epoll,比epoll更快,批量处理IO,未来主流;

✅ Tokio正在适配io_uring,性能再翻倍。

✅ Rust演示(mio = Tokio底层,极简epoll事件监听):

toml 复制代码
[dependencies]
mio = { version = "1.0", features = ["net"] }
rust 复制代码
use mio::{Events, Interest, Poll, Token};
use std::net::TcpListener;

// epoll核心演示:监听网络事件
fn main() -> std::io::Result<()> {
    let mut poll = Poll::new()?;
    let mut events = Events::with_capacity(10);
    let listener = TcpListener::bind("127.0.0.1:8080")?;

    // 把socket注册到epoll
    poll.registry().register(&listener, Token(0), Interest::READABLE)?;
    println!("epoll监听8080端口,等待连接...");

    // 内核主动通知事件
    loop {
        poll.poll(&mut events, None)?;
        for _ in events.iter() {
            println!("收到客户端连接!epoll工作正常");
        }
    }
}

模块4:Linux权限(Docker/容器底层原理!)

一句话总述:容器 = namespace(隔离) + cgroup(限制)

1. UID / GID

  • UID:用户身份证(root=0,普通用户=1000);
  • GID:用户组身份证,权限按用户/组划分。

2. Capability

拆分root权限:不用给应用root权限,只给「绑定80端口」的小权限。

3. cgroup(资源限制)

✅ 比喻:给学生定规矩,最多用2根笔、1张纸

✅ 作用:限制进程CPU、内存、带宽,防止Web服务占满服务器。

4. namespace(环境隔离)

✅ 比喻:给学生造一个独立教室 ,他以为自己独占整栋楼;

✅ 作用:隔离PID、网络、文件、用户 → 这就是容器!

✅ 教学金句:

容器不是虚拟机!容器就是一个被namespace隔离、cgroup限制的普通Linux进程!


模块5:工具链 + ELF + 编译原理

1. 必备工具(Web排查神器)

给学生直接记:

  • strace:看进程所有系统调用(查服务卡顿);
  • perf:查CPU性能瓶颈;
  • gdb:调试进程;
  • lsof:看进程打开的FD(网络连接/文件);
  • readelf:看ELF文件信息。

2. ELF 文件

Linux的可执行文件格式(=Windows的exe),存代码、依赖、数据。

3. 静态编译 / 动态编译

  • 动态编译:共享系统库,文件小,换机器可能缺库;
  • 静态编译 :把所有依赖打包进程序,到处运行
    ✅ Rust特色:默认静态编译!所以Rust二进制文件拷贝到任意Linux机器都能跑,不用装依赖。

总结

  1. 内核是管家,应用是住户,用户态↔内核态切换是性能关键;
  2. epoll是Web高并发的灵魂,Tokio/Axum/Nginx全靠它;
  3. 容器=隔离(namespace)+限制(cgroup),不是虚拟机;
  4. Linux一切皆文件,FD是文件/连接的门牌号,inode是文件身份证;
  5. Rust默认静态编译+epoll异步,天生适合Linux高并发Web服务。

这套讲解通俗、有比喻、有代码、有实战关联,学生完全能听懂,你讲起来也轻松!