ℹ️ 写在前面

本文尚未完成,但近期不会补充完整。

这里记录一下还需要写的部分:

  • 实验代码与解析

实验3:Linux 进程管理

基本原理与知识点

  1. 读端与写端的区分

    piep() 系统调用中:

    • fd[0]读端

    • fd[1]写端

  2. close() 怎么理解

    通俗易懂的说,我们可以把文件描述符理解为电话号码。

    1
    2
    
    pipe(fd)    // 创建管道;相当于申请了一对专线电话,
               // 其中一条只能听(fd[0]),一条只能说(fd[1])
    

    假设pipe(fd) 后,fd[0]=3,fd[1]=4。

    子进程创建后,会复制父进程的整个”通讯录“,而不是新建管道。

    也就是说在子进程中,依然是fd[0]=3,fd[1]=4。

    让我们回到 close() ,可以这样理解,一个进程执行 close(fd[1]) ,意味着它删除了这条拿来说的电话线(fd[1] 是写端),此后它不能再往这个管道中写数据,只能读。

    但值得注意的是,父进程 close(fd[1]) 后,子进程不受影响,子进程依然可以写数据。

    一句话总结:每个进程有自己的 fd,但指向同一个管道。close() 只是减少该管道在这个进程中的引用。

  3. read() 函数返回什么?

    read() 的典型用法如下所示:

    1
    
    ssize_t n = read(fd, buf, count);
    
    返回值 含义 场景
    > 0 实际读取的字节数 正常情况,可能小于 count(短读)
    0 EOF(文件结束) 所有写端都已关闭
    -1 出错 被信号中断、非阻塞无数据等
  4. 创建管道时,读写两端自动打开,无需 open()

  5. 文件描述符(fd)是啥?

    文件描述符是进程级别的”文件句柄“,是一个非负整数(0,1,2,3,…)。

    可以这样理解,一个进程中有一张表格,列举了进程打开的I/O资源(文件、管道、设备等),这个文件描述符可以理解为这个表格的整数索引

    进程通过编号(文件描述符号),可以让内核操作资源,无需知道文件在内核中的复杂结构。

    • fd 是进程私有的,比方说有两个进程,进程 A 的 fd=3 可能指向管道;进程 B 的 fd=3 可能指向完全不同的文件。

    • fork() 创建子进程会复制 fd,但指向同一个内核对象

    • fd值有常见的约定:

      fd 值 含义 对应 C 常量
      0 标准输入(stdin) STDIN_FILENO
      1 标准输出(stdout) STDOUT_FILENO
      2 标准错误(stderr) STDERR_FILENO
      ≥3 用户打开的文件/管道
  6. pipe(fd) 做了什么?

    在管道实验中,我们常会看到:

    1
    2
    
    int fd[2];
    pipe(fd);
    

    fd初始化时是空的,pipe(fd)后,创建一个新的管道,并分配两个文件描述符,对管道的读端与写端。

    这里我们能进一步理解文件描述符的本质:它就是一个数,用来标识一个内核资源。

  7. 写端、读端是否需要关闭?

    一句话讲明:不关闭不需要的端会导致 read() 永远阻塞

    • 写数据的进程不关闭读端(fd[0])

      能写入,不受影响。

      当所有写端都关闭后,读端应该收到 EOF(read() 返回 0),但如果你自己还开着读端,系统认为“还有进程可能写入”,读端会永远阻塞在 read() 上。

    • 读数据的进程不关闭写端(fd[1])

      能读取,如果管道里有数据就可以正常读。

      当管道读空后,read() 不会返回0(EOF),而是阻塞等待,因为系统看到你还开着写端,认为“迟早还要写数据进来”。

    一句话解释:管道是单向通信

  8. 关闭写/读端,减少引用计数

    这个计数可以涉及两方面:

    • 文件结构体的引用计数

      调用 pipe(fd) 时,内核会创建:

      • 两个 struct file 对象(代表管道的两端)

      • 一个底层的 pipe_inode_info (管道的实际缓冲区)

      每个进程持有文件描述符(fd)时,内核中的 struct file引用计数f_count)会增加:

      • fork() 时,子进程继承 fd,引用计数 +1

      • close() 时,引用计数 -1

      当这个引用计数降到0,内核才释放这个文件结构体。

    • 管道的“写者计数”(Writer Count)

      管道内部同样存在写端计数器。内核需要知道:“还有没有进程要往这个管道写数据?”

      调用 close(fd[1]) 时,内核执行:

      1. 减少 file 结构体的引用计数

      2. 检查这是否是最后一个写端。

        如果是,唤醒阻塞的读进程,让它们知道“不会再有数据了”。

    我们看到一个反面教材:

    1. 父进程:read(fd[0]),阻塞等待数据

    2. 子进程:write(fd[1]),写入消息

    3. 子进程:close(fd[1]),但 fd[0] 还开着!!

    此时,虽然子进程写完且关闭了写端,但还持有读端,内核认为:

    还有进程持有读端,它可能 fork 出新的子进程来继续写入

    因此,计数不会变成0,父进程的 read() 会永远阻塞。

  9. 管道阻塞场景

    既然说到阻塞,我们来看几种阻塞场景

    1. 读空管:当管道中没有数据时,read 会阻塞,知道有进程写数据才被唤醒

    2. 写满管:管道有容量限制(通常是64KB)。管道满时,写进程必须等读进程“腾出空间”

    3. 多写一端关闭:当有多个写进程时,只要还有一个写进程持有 fd[1],读端会认为”还可能有人写“,继续阻塞; read 返回 0,表示所有写端都关闭了。

    4. 阻塞是线程安全的,唤醒是有序的:

      这个场景稍微复杂些:

      • 父进程开始读,此时管道是空的,阻塞ing

      • 子进程依次分别写消息

      • 情况出现了!! 父进程读到子进程1的数据,直接返回,结束。

      read是流式读取,有数据就读。

      如果需要父进程读完全部数据,必须让子进程全部写完,再读。

      这也是我们实验的重点之一。

  10. POSIX 有名信号量

    核心API(四个动作)
    函数 作用 关键参数
    sem_open(name, flags, mode, value) 创建或打开 name 必须以 / 开头(如 "/my_sem");O_CREAT 创建;value 初始值
    sem_wait(sem) P 操作(等待) 值 > 0 则减 1 并返回;值 = 0 则阻塞
    sem_post(sem) V 操作(释放) 值加 1;若有进程等待,唤醒其中一个
    sem_close(sem) 关闭句柄 进程退出时自动调用,但不删除信号量本身
    sem_unlink(name) 删除信号量 从系统中移除;若其他进程正持有,待其 close 后销毁

    这里展开讲讲 sem_open 的第2,3个参数:

    • 标志位:控制打开方式,常用按位或(|)组合

      标志 含义 效果
      O_CREAT 创建模式 信号量不存在则创建存在则直接打开(最常用)
      O_EXCL 排他创建 必须与 O_CREAT 配合使用,若信号量已存在则报错(防止重复初始化)
      O_RDWR 读写打开 隐式默认,无需显式指定
      1
      2
      
      sem_open("/my_sem", O_CREAT | O_EXCL, 0644, 1);  // 强制新建,已存在则失败(适合初始化进程)
      sem_open("/my_sem", O_CREAT, 0644, 1);           // 通用模式:没有就建,有就直接用
      
    • 权限位:仅当实际创建新信号量时生效(如果信号量已存在,此参数被忽略)

      采用 Unix 标准权限格式,通常写为 八进制数

      有名信号量在内核中对应一个特殊文件(/dev/shm/sem.my_sem)

      0(特殊位)6(文件所有者权限)4(组用户权限)4(其他用户权限)

      0644表示:创建者可读写,其他进程只能读(无法修改信号量值,但可查询状态)

      常见取值:

      • 0644:普通共享(所有用户可读,仅所有者可写)

      • 0666:完全共享(所有用户可读写,风险较高)

      • 0600:私有(仅创建者自身进程可访问)

    典型使用流程
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    // 1. 创建/打开(类似 open())
    sem_t *sem = sem_open("/project_lock", O_CREAT, 0644, 1);
    
    // 2. 临界区保护
    sem_wait(sem);          // 进入,获取锁
        // ... 访问共享资源
    sem_post(sem);          // 退出,释放锁
    
    // 3. 清理(必须显式删除!)
    sem_close(sem);
    sem_unlink("/project_lock");  // 通常由最后一个进程或初始化进程执行
    

    值得注意的:有名信号量生命周期独立于进程,即使创建进程仍存活,其他进程可继续打开使用。

    这也是和无名信号量(sem_init)的区别之一,无名信号量会随进程结束自动销毁,它仅适用于 fork 后的父子进程。

题目2:实现一个管道通信程序

由父进程创建一个管道,然后再创建3个子进程,并由这三个子进程利用管道与父进程之间进行通信:子进程发送信息,父进程等三个子进程全部发完消息后再接收信息。

通信的具体内容可根据自己的需要随意设计,要求能试验阻塞型读写过程中的各种情况,测试管道的默认大小,并且要求利用Posix信号量机制实现进程间对管道的互斥访问。

运行程序,观察各种情况下,进程实际读写的字节数以及进程阻塞唤醒的情况。

我们先来通过一些小demo看看管道的基本使用方法

值得注意的:demo中有一些不严谨的写法(如写进程完成写后,没有关闭写端,而是直接return 0),或许不会导致运行问题(进程退出时会自动清理),但在正式实验中不可以这样写。

demo1:基础管道(验证单向通信)

父进程写,子进程读

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    int fd[2];
    pipe(fd);

    if (fork() == 0) {
        // 子进程:读
        close(fd[1]);    // 关闭写端
        char buf[100];

        printf("子进程:等待数据...\n");
        int n = read(fd[0], buf, sizeof(buf));
        printf("子进程:读到 %d 字节:%s\n", n, buf);
    } else {
        // 父进程:写
        close(fd[0]);
        sleep(2);        // 观察子进程是否阻塞
        write(fd[1], "Hello", 5);
        printf("父进程:已发送\n");
    }
    return 0;
}

子进程:等待数据…

(阻塞2秒)

父进程:已发送

子进程:读到 5 字节:Hello

demo2:管道容量测试(观察阻塞)

管道满时,写阻塞

原子性写入限制:要么都写,要么都不写,没有部分写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
    int fd[2];
    pipe(fd);

    // 设置为非阻塞(仅用于演示容量)
    fcntl(fd[1], F_SETFL, O_NONBLOCK);

    int count = 0;
    char buf[4096];    // 4KB

    // 不断写入直到阻塞
    while (1) {
        int n = write(fd[1], buf, sizeof(buf));
        if (n < 0) {
            perror("write");
            break;
        }
        count += n;
        printf("已写入:%d bytes\n", count);
    }
}

已写入:4096 bytes

已写入:8192 bytes

已写入:12288 bytes

……

已写入:65536 bytes

write: Resource temporarily unavailable

demo4:数据混杂问题

3个子进程同时写,会导致数据混杂,这也是为什么正式实验需要引入信号量机制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <stdlib.h>  

int main() {
    int fd[2];
    if (pipe(fd) == -1) {
        perror("pipe");
        exit(1);
    }

    for (int i = 0; i < 3; i++) {
        pid_t pid = fork();

        if (pid == -1) {  // 检查 fork 失败
            perror("fork");
            exit(1);
        }

        if (pid == 0) {  // 子进程
            close(fd[0]);  // 关闭读端

            char msg = '0' + i;
            for (int j = 0; j < 100; j++) {
                if (write(fd[1], &msg, 1) != 1) {
                    perror("write");
                    exit(1);
                }
                usleep(1000);  // 1000 微秒(1毫秒),避免过快
            }

            printf("子进程 %d 写完\n", i);
            fflush(stdout);  // 确保输出立即显示
            close(fd[1]);    // 显式关闭写端(好习惯)
            exit(0);         // 使用 exit 而非 return,明确结束进程
        }
    }

    // 父进程
    close(fd[1]);  // 必须关闭写端,否则 read 永远阻塞

    char result[301];
    int total = 0;
    int n;

    // 循环读取直到 EOF,而不是一次性读 300(更健壮)
    while ((n = read(fd[0], result + total, 300 - total)) > 0) {
        total += n;
    }
    result[total] = '\0';

    printf("实际读取 %d 字节,结果:%s\n", total, result);

    // 等待所有子进程结束,避免僵尸进程
    for (int i = 0; i < 3; i++) {
        wait(NULL);
    }

    return 0;
}

子进程 1 写完

子进程 0 写完

实际读取 300 字节,结果:012102100212101020122010212011201201201020210210021021021020101201201221002101212021021012021010212021021012012012012210102120021120201210212102012012012012210021021120210201021201201021021200122101202100120120120120121002112010210201201210201102102021012012010120122101202102101210201220120102120122

子进程 2 写完

(多进程并发时,子进程实际开始、打印、结束顺序是混杂的)

demo4:POSIX 有名信号量基础

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <semaphore.h>
#include <unistd.h>

int main() {
    // 创建有名信号量,初值为 1(互斥锁)
    // 参数:名字(必须以 / 开头),标志,权限,初值
    sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);

    if (fork() == 0) {
        printf("子进程:等待信号量...\n");
        sem_wait(sem);
        printf("子进程:获得锁,持有3秒...\n");
        sleep(3);
        printf("子进程:释放锁\n");
        sem_post(sem);    // V操作,值从0变1,释放锁
        sem_close(sem);
        return 0;
    }

    sleep(1);    // 确保子进程先拿到锁
    printf("父进程:等待信号量...\n");
    sem_wait(sem);    // 此时值为0,阻塞,等待子进程释放
    printf("父进程:获得锁\n");
    sem_post(sem);

    sem_close(sem);
    sem_unlink("/my_sem");    // 删除信号量(清理)
    return 0;
}

子进程:等待信号量…

子进程:获得锁,持有3秒…

(阻塞3秒)

父进程:等待信号量…

(等待一段时间)

子进程:释放锁

父进程:获得锁

注意!! 这个源代码的编译命令不一样:

1
gcc lab3-2-demo.c -o  -pthread

-pthread 是 GCC 的 POSIX 线程支持选项,它告诉编译器:

  • 预处理阶段:定义 _REENTRANT 宏(表示可重入代码)

  • 链接阶段:自动链接 libpthread 线程库

实验代码