Stanford-CS110-Computer-Systems-Principles-Notes-全-
Stanford CS110 Computer Systems Principles Notes(全)

CS110 课程介绍与文件系统基础 🖥️
在本节课中,我们将学习 CS110 课程的整体概览,并深入探讨计算机系统的基础知识,特别是 Linux 文件系统的核心概念和操作。

课程概述 📚

CS110 是计算机系统原理课程。本课程假设你已经修过 CS106A、CS106B 和 CS107 或同等课程。课程内容具有挑战性,将涵盖大型程序的构建、数据结构、C++ 类以及系统级编程概念。
课程主要围绕五个核心主题展开:
- Unix 文件系统:深入理解文件系统的构建方式、文件追踪和存储机制。
- 多进程:学习如何让程序同时运行多个任务,包括进程创建(
fork)和协调。 - 信号处理:作为多进程的一部分,学习进程间通信的基本机制。
- 多线程:在单个进程内实现并发任务,并处理相关的同步问题。
- 网络编程:构建客户端和服务器程序,实现跨机器通信。
本课程包含八个编程作业、一次期中考试和一次期末考试。课程将大量使用 C 和 C++ 进行编程,并侧重于理解操作系统底层原理。

讲师与课程安排 👨🏫


讲师 Chris Gregg 拥有电气工程背景和教学经验。课程将在周一、周三的固定时间进行讲座,周五则安排实验课。课程网站、Piazza 和 Slack 将作为主要的信息发布和交流平台。
课程评分中,作业占 40%,期中考试占 20%,期末考试占 35%,课堂参与占 5%。迟交作业会有相应的分数折减政策。

深入文件系统 💾


上一节我们介绍了课程的整体框架,本节中我们来看看第一个核心主题:Unix/Linux 文件系统的基础操作。

文件与目录操作

在终端中,我们使用 ls 命令列出目录内容。使用 ls -al 可以查看详细信息,包括以点(.)开头的隐藏文件。其中,. 代表当前目录,.. 代表上级目录。
文件权限

ls -l 输出的信息中包含了文件权限,例如 -rwxr-xr-x。权限分为三组:
- 所有者权限:文件创建者的权限。
- 组权限:文件所属用户组的权限。
- 其他用户权限:系统上其他所有用户的权限。

每组权限包含三个字符,分别代表:
r:读权限。w:写权限。x:执行权限。

权限可以用八进制数字表示,每位数字对应一组权限(r=4, w=2, x=1)。例如,权限 rwxr-xr-x 可以表示为 755。



系统调用:打开与创建文件





在 C 语言中,我们可以使用系统调用来直接与操作系统交互,进行文件操作。open 是一个关键的系统调用,用于打开或创建文件。






以下是 open 系统调用的基本用法:
#include <fcntl.h>
#include <unistd.h>

int open(const char *pathname, int flags, mode_t mode);
pathname:文件路径。flags:指定打开方式,如O_RDONLY(只读)、O_WRONLY(只写)、O_RDWR(读写)、O_CREAT(创建文件)、O_EXCL(与O_CREAT共用,确保文件不存在时才创建)。mode:指定新创建文件的权限(八进制数,如0644)。






系统调用成功时返回一个文件描述符(一个小的非负整数),失败时返回 -1。





权限掩码 (umask)



创建文件时,实际生效的权限是 mode 参数与当前 umask 的反码进行按位与操作的结果。umask 指定了哪些权限位应该被禁用。


例如,如果 umask 是 022(二进制 000010010),而 mode 设置为 0666(二进制 110110110),则最终文件权限为 0644(110100100),即组和其他用户的写权限被屏蔽。





以下程序演示了如何获取和设置 umask:
#include <sys/stat.h>
#include <sys/types.h>
#include <stdio.h>
int main() {
mode_t old_mask = umask(0); // 设置umask为0,并获取旧值
printf("Old umask was: %03o\n", old_mask);
umask(old_mask); // 恢复旧umask
return 0;
}





实践:文件复制程序






理解了文件打开和读写后,我们可以实现一个简单的文件复制程序,模拟 cp 命令的基本功能。






以下是核心逻辑的代码框架:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>


#define BUFFER_SIZE 1024



int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <source> <destination>\n", argv[0]);
return 1;
}
int src_fd = open(argv[1], O_RDONLY);
if (src_fd == -1) { perror("Error opening source file"); return 1; }
int dest_fd = open(argv[2], O_WRONLY | O_CREAT | O_EXCL, 0644);
if (dest_fd == -1) {
if (errno == EEXIST) {
fprintf(stderr, "Error: Destination file already exists.\n");
} else {
perror("Error creating destination file");
}
close(src_fd);
return 1;
}
char buffer[BUFFER_SIZE];
ssize_t bytes_read;
while ((bytes_read = read(src_fd, buffer, BUFFER_SIZE)) > 0) {
ssize_t bytes_written = 0;
while (bytes_written < bytes_read) {
ssize_t result = write(dest_fd, buffer + bytes_written, bytes_read - bytes_written);
if (result == -1) { perror("Write error"); break; }
bytes_written += result;
}
}
close(src_fd);
close(dest_fd);
return 0;
}
关键点说明:
- 错误处理:检查
open、read、write的返回值,并使用perror或errno输出错误信息。 - 循环读取与写入:因为
read和write调用可能无法一次性传输请求的所有字节(尤其是在网络或特定系统条件下),所以需要在循环中处理,直到所有数据操作完成。 - 资源管理:使用
close系统调用关闭文件描述符,释放系统资源。






总结 🎯





本节课中我们一起学习了 CS110 课程的初步介绍和 Linux 文件系统的基础。我们了解了:
- 课程的核心主题和学习目标。
- 文件权限的概念和八进制表示法。
- 如何使用
open、read、write、close等系统调用进行底层的文件操作。 - 权限掩码 (
umask) 如何影响新文件的创建。 - 如何构建一个简单的文件复制程序,并处理其中的读写循环和错误情况。




这些关于文件系统的知识是理解后续多进程、多线程等高级主题的重要基石。在接下来的课程中,我们将利用这些基础,探索程序如何并发执行并相互通信。
课程 P10:进程与信号分析实战 🧩
在本节课中,我们将通过分析几个涉及进程创建(fork)、信号处理以及进程间同步的复杂C程序,来深入理解并发编程中的竞态条件和确定性输出。我们将逐步拆解每个程序,明确其执行流程和所有可能的输出序列。
概述
本节课将重点分析三个具有挑战性的编程问题。每个问题都涉及fork、信号和wait系统调用。我们的目标是运用对进程和信号机制的理解,推导出程序所有可能的输出结果。关键在于理解操作执行的原子性、信号处理的时机以及进程间的父子等待关系。
问题一:信号处理与输出顺序
上一节我们回顾了进程的基本概念。本节中,我们来看看一个结合了信号处理的程序,分析其输出的可能性。
考虑以下程序:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void bat(int sig) {
printf("pirate\n");
exit(0);
}
int main() {
signal(SIGUSR1, bat);
pid_t pid = fork();
if (pid == 0) {
printf("ghost\n");
return 0;
} else {
kill(pid, SIGUSR1);
printf("ninja\n");
wait(NULL);
}
return 0;
}
假设条件:
printf语句是原子的(即执行时不会被中断)。- 信号处理器会在子进程结束前被调用(除非程序已完全终止)。
核心问题:输出序列 ninja ghost 是否可能出现?

分析过程:
- 父进程
fork出子进程。 - 父进程执行
kill(pid, SIGUSR1)向子进程发送SIGUSR1信号,然后打印ninja。 - 子进程打印
ghost,然后return 0。


要出现ninja先于ghost打印,父进程必须在子进程打印ghost前发送信号并执行自己的printf。这在理论上是可能的。

然而,要使得ghost在pirate之前打印,则要求信号在子进程打印ghost之后、但在其从main返回之前才被传递和处理。但一旦信号发出,在子进程执行流程中(包括printf("ghost")和return 0之间),信号处理器随时可能被触发。如果信号在ghost打印后、返回前被处理,则会执行bat函数打印pirate并exit(0),导致ghost后的return 0不会执行。
因此,输出序列 ninja ghost 不可能出现。可能的序列是ghost ninja pirate或ninja pirate(后者发生在信号在子进程打印ghost前就已传递并处理的情况下)。
问题二:多级fork与循环计数器
理解了信号的影响后,我们来看一个更复杂的多进程创建案例。本节将分析一个包含循环和条件分支的fork程序。
考虑以下程序:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int counter = 0;
while (counter < 2) {
pid_t pid = fork();
if (pid > 0) {
break;
}
counter++;
printf("%d", counter);
}
if (counter > 0) {
printf("%d", counter);
}
pid_t pid = fork();
if (pid > 0) {
wait(NULL);
counter += 5;
printf("%d", counter);
}
return 0;
}
假设条件:printf是原子的,所有系统调用成功。
问题:列出程序所有可能的输出序列(每个数字代表一次printf输出)。
逐步分析:
- 初始状态:
main开始,counter = 0。 - 第一次循环:
fork()创建子进程1(Child1)。- 父进程:
pid > 0,执行break跳出循环。随后counter为0,不满足counter > 0,跳过第一个if。执行下一个fork。 - 子进程1:
pid == 0,counter增至1,打印1。
- 子进程1的第二次循环:
counter为1,满足while条件。fork()创建子进程2(Child2)。- 子进程1(作为Child2的父进程):
pid > 0,跳出循环。此时counter为1,满足counter > 0,打印1。执行下一个fork。 - 子进程2:
pid == 0,counter增至2,打印2。counter为2,不满足while条件,退出循环。counter > 0成立,再次打印2。执行下一个fork。
- 关键执行路径与等待关系:
- 父进程在等待其
fork产生的子进程(即Child1)终止。 - 子进程1在等待其
fork产生的子进程(即Child2)终止后,才执行counter += 5(变成6)并打印6。 - 子进程2没有子进程需要等待,直接结束。
- 父进程在等待其
- 确定性与非确定性:
- 第一个打印的1(来自Child1)是确定的。
- 最后一个打印的5(来自初始父进程,在等待Child1结束后
counter=0+5)是确定的。 - 子进程1打印的6(在等待Child2结束后)是确定的,且位于第一个1之后、5之前。
- 子进程1打印的第二个1和子进程2打印的两个2,它们之间的执行顺序是非确定性的(竞态条件)。
以下是所有可能的输出序列(括号内为打印进程):
- 1(Child1), 1(Child1), 2(Child2), 2(Child2), 6(Child1), 5(Parent)
- 1(Child1), 2(Child2), 1(Child1), 2(Child2), 6(Child1), 5(Parent)
- 1(Child1), 2(Child2), 2(Child2), 1(Child1), 6(Child1), 5(Parent)

变体思考:如果将if (counter > 0)改为if (counter >= 0),那么初始父进程也会打印其counter值0。这个0可以出现在第一个1之前、之后或中间两个数字的任意位置,导致可能的输出序列数量大幅增加。
问题三:waitpid与进程链

分析了平行分支后,我们最后考察一个线性进程链的场景,重点在于waitpid的返回值如何影响流程。
考虑以下程序:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>





int main() {
printf("0");
pid_t pid = fork();
if (pid == 0) {
printf("1");
pid_t pid2 = fork();
if (pid2 == 0) {
printf("2");
return 0;
} else {
waitpid(pid2, NULL, 0);
printf("3");
return 0;
}
} else {
// 父进程
int status;
// waitpid 可能失败,因为子进程可能已结束
pid_t ret = waitpid(pid, &status, 0);
if (ret > 0) {
printf("4");
} else {
printf("5");
}
printf("6");
}
return 0;
}
假设:printf原子且刷新,fork成功。waitpid在目标子进程不存在时返回-1。




问题:程序的输出是什么?



分析过程:
- 初始进程(P0)打印0,然后
fork出子进程P1。 - P1(子进程)执行流:
- 打印1。
fork出孙子进程P2。- P2(孙子进程):打印2,然后
return 0结束。 - P1:调用
waitpid(pid2, ...)等待P2。P2结束后,P1打印3,然后return 0结束。
- P0(父进程)执行流:
- 调用
waitpid(pid, ...)等待P1。 - 关键点:这里存在竞态条件。P0的
waitpid调用时,P1可能已经结束(如果P0被调度得较晚),也可能尚未结束。 - 如果P1尚未结束,
waitpid成功等待到P1,返回P1的PID(>0),打印4。 - 如果P1已经结束,
waitpid失败,返回-1,打印5。 - 最后,P0总是打印6。
- 调用





因此,该程序有两种可能的输出:
- 0 1 2 3 4 6 (当P0的
waitpid成功等待到P1时) - 0 1 2 3 5 6 (当P1先于P0执行
waitpid而结束时)








总结





本节课中,我们一起深入分析了三个涉及多进程和信号的复杂编程问题。




- 在问题一中,我们学习了信号处理函数的执行时机如何严格限制输出序列的可能性,
ninja ghost的顺序由于信号处理的必然介入而无法实现。 - 在问题二中,我们练习了通过画进程树或跟踪执行流来分析多级
fork和循环,明确了确定性与非确定性(竞态条件)输出的部分,并推导出所有可能结果。 - 在问题三中,我们看到了
waitpid的返回值如何依赖于进程调度的时序,导致程序存在两种不同的合法输出路径。




掌握这些分析技巧对于理解和调试并发程序至关重要,也是应对相关考试题目的关键。
课程 P11:第10讲 从C线程到C++线程 🧵


在本节课中,我们将学习如何从C语言的Pthreads过渡到C++11内置的线程库。我们将探讨C++线程的基本用法、如何传递参数、以及如何处理多线程编程中常见的竞争条件问题。

期中考试安排 📝
上一节我们介绍了多进程和多线程的基本概念,本节中我们来看看本周的重要安排。
期中考试定于本周四晚上6点到8点,地点在Hewlett 200教室。考试将使用Blue Book系统进行。
以下是关于考试的重要信息列表:
- 考试形式为在笔记本电脑上使用Blue Book程序答题并提交。
- 考试内容加密,将在考前发布,进入考场后获得密码即可开始。
- 考试时长为两小时,但预计多数同学可在一个半小时内完成。
- 允许携带一张正反面写有个人笔记的纸张进入考场。
- 考试时会提供一份有限的参考资料,包含常用函数原型和常量。
- 如果需要特殊考试安排,请务必提前通过邮件联系老师。





考试内容将涵盖信号处理、多进程编程等,形式与练习题相似,包括编程题和简答题。











本周作业:斯坦福Shell 🐚

接下来,我们看看本周的作业。这是一个构建简易Shell的程序,名为“斯坦福Shell”(Stanford Shell)。



这个作业要求你实现一个功能相对完整的Shell,需要处理以下核心功能:
- 执行基本命令,并支持前台/后台运行。
- 使用
jobs命令查看所有后台作业。 - 使用
fg命令将后台作业调至前台。 - 使用
bg命令重启一个已停止的后台作业。 - 使用
slay命令向整个进程组发送信号以终止一组进程。 - 支持管道操作,例如
ls | grep | cut。 - 支持输入/输出重定向。
- 正确处理
Ctrl+C(SIGINT) 和Ctrl+Z(SIGTSTP) 信号,使其影响前台进程而非Shell本身。

作业的核心难点在于管理多个子进程、实现管道以及处理信号。建议按照提供的里程碑顺序完成,并充分利用测试驱动文件进行调试。作业截止日期为下周三。




从C的Pthreads到C++线程 🔄
现在,让我们正式进入C++线程的世界。与C的Pthreads相比,C++11内置的线程库语法更简洁,集成度更高。
C++线程同样共享进程内存,每个线程拥有独立的栈,但可以访问共享的全局数据。它们被认为是“轻量级进程”,能够真正在多核CPU上并行执行。



基础示例:充电程序

我们从一个简单的程序开始,创建多个线程,每个线程打印一条信息。



#include <iostream>
#include <thread>
#include <vector>
// 需要包含自定义的 oslock.h 来保证 cout 的线程安全
#include "oslock.h"



void recharge() {
oslock lock;
std::cout << "I recharge by spending time alone." << std::endl;
osunlock unlock;
}

int main(int argc, char *argv[]) {
const size_t kNumIntroverts = 6;
std::vector<std::thread> introverts(kNumIntroverts);
for (size_t i = 0; i < kNumIntroverts; ++i) {
// 创建线程并执行 recharge 函数
std::thread t(recharge);
// 使用 swap 将线程对象移入数组
introverts[i].swap(t);
}
for (std::thread& introvert : introverts) {
introvert.join(); // 等待所有线程结束
}
return 0;
}
关键点说明:
std::thread t(recharge):创建并启动一个执行recharge函数的新线程。introverts[i].swap(t):这是一个C++的“移动”操作,将新创建的线程对象t移入数组introverts,避免不必要的拷贝。introvert.join():主线程等待该子线程执行完毕。








向线程传递参数 📨

线程函数可以接受参数。C++线程库允许你以值传递或引用传递的方式向线程函数传参。




以下是传递参数的示例程序:
#include <iostream>
#include <thread>
#include <vector>
#include "oslock.h"







void greeter(size_t id, size_t numTimes) {
for (size_t i = 0; i < numTimes; ++i) {
oslock lock;
std::cout << "Greeter #" << id << " says 'Hello!'" << std::endl;
osunlock unlock;
// 模拟一些处理时间
struct timespec ts = {0, random() % 1000000000};
nanosleep(&ts, NULL);
}
oslock lock;
std::cout << "Greeter #" << id << " has issued all greetings and goes home." << std::endl;
osunlock unlock;
}




int main(int argc, char *argv[]) {
std::cout << "Welcome to Greetland!" << std::endl;
const size_t kNumGreeters = 6;
std::thread greeters[kNumGreeters];
for (size_t i = 0; i < kNumGreeters; ++i) {
// 创建线程,并传递参数 i+1 (值传递)
greeters[i] = std::thread(greeter, i + 1, i + 1);
}
for (std::thread& greeter : greeters) {
greeter.join();
}
std::cout << "Everyone has been greeted. The end." << std::endl;
return 0;
}
关键点说明:
std::thread(greeter, i + 1, i + 1):第一个greeter是函数名,后续参数i+1将按值传递给greeter函数。- 若需传递引用,需使用
std::ref()进行包装,例如std::ref(sharedVariable)。












处理竞争条件与互斥锁 🔐
多线程访问共享资源时,会产生竞争条件。我们通过一个模拟售票的程序来演示这个问题及其解决方案。

存在竞争条件的版本



假设有多个售票代理线程共享一个剩余票数变量。
size_t remainingTickets = 250; // 共享变量
void ticketAgent(size_t id, size_t& tickets) {
while (tickets > 0) {
// 模拟处理票务的时间
handleCall();
tickets--; // 非原子操作,存在竞争条件!
oslock lock;
std::cout << "Agent #" << id << " sold a ticket (" << tickets << " more to be sold)." << std::endl;
osunlock unlock;
takeBreak();
}
oslock lock;
std::cout << "Agent #" << id << " notices all tickets are sold and goes home." << std::endl;
osunlock unlock;
}
问题:tickets-- 不是原子操作,多个线程可能同时读取、修改该变量,导致票数计算错误(如卖超或负数)。



使用互斥锁解决问题



互斥锁可以确保同一时间只有一个线程能进入被锁保护的“临界区”代码。
#include <mutex>



std::mutex ticketLock; // 全局互斥锁
size_t remainingTickets = 250;

void ticketAgent(size_t id, size_t& tickets, std::mutex& lock) {
while (true) {
lock.lock(); // 加锁
if (tickets == 0) {
lock.unlock(); // 检查后立即解锁
break;
}
tickets--; // 在锁的保护下修改共享变量
size_t myTicket = tickets;
lock.unlock(); // 尽快解锁,让其他线程进入
// 在锁外执行耗时的操作(如打印、模拟工作)
handleCall();
oslock outLock;
std::cout << "Agent #" << id << " sold a ticket (" << myTicket << " more to be sold)." << std::endl;
osunlock outUnlock;
takeBreak();
}
oslock lock;
std::cout << "Agent #" << id << " notices all tickets are sold and goes home." << std::endl;
osunlock unlock;
}



int main() {
std::thread agents[10];
for (size_t i = 0; i < 10; ++i) {
// 传递共享变量和互斥锁的引用
agents[i] = std::thread(ticketAgent, 100 + i, std::ref(remainingTickets), std::ref(ticketLock));
}
for (std::thread& agent : agents) { agent.join(); }
return 0;
}
关键点说明:
std::mutex:定义互斥锁。lock.lock()/lock.unlock():手动加锁与解锁。确保锁的粒度尽可能小,只包围访问共享资源的代码,避免长时间持有锁导致性能下降。std::ref():用于向线程函数传递引用类型的参数(如remainingTickets和ticketLock)。




总结 🎯


本节课中我们一起学习了:
- C++线程基础:如何使用
std::thread创建线程、传递参数以及使用join等待线程结束。 - 线程安全输出:通过自定义的
oslock/osunlock保证std::cout在多线程环境下的输出完整性。 - 竞争条件:理解了多线程并发修改共享数据时导致的问题。
- 互斥锁:掌握了使用
std::mutex保护临界区,解决竞争条件的基本方法。记住要尽量缩短锁的持有时间以提高程序效率。

从C的Pthreads到C++线程,我们拥有了更现代、更易用的工具来处理并发编程。在接下来的课程中,我们将探讨更高级的同步机制,如条件变量和信号量。


🍽️ 课程 P12:第11讲 多线程、条件变量和信号量

在本节课中,我们将学习多线程编程中的经典问题——哲学家就餐问题。我们将探讨如何通过互斥锁、条件变量和信号量来解决线程间的资源竞争与死锁问题,并编写代码进行实践。


📅 课程安排与期中提醒
本周没有实验课。取而代之的是,要求大家观看一个约五分钟的短视频。这个视频是斯坦福毕业生关于计算机科学的简短分享。观看该视频将作为本周的实验签到。大家可以利用这一小时二十分钟的时间进行学习或完成其他课程作业。





🔍 回顾与调试:一个编译错误的解决

在上一节课的编码演示中,我们遇到了一个编译错误。问题出现在向线程函数传递参数时,类型不匹配。具体来说,我们试图将一个 size_t 类型的变量以 unsigned int 类型的引用进行传递。编译器因此报错。




解决方案是修正类型声明,确保传递的引用类型与参数期望的类型完全一致。修正后代码如下:




// 修正前:传递了错误的类型引用
threadFunction(..., (unsigned int&)remainingTickets, ...);


// 修正后:使用正确的 size_t 类型
threadFunction(..., (size_t&)remainingTickets, ...);






这个错误提醒我们,编程中遇到难以发现的 Bug 是常事,即使经验丰富的程序员也不例外。关键在于耐心分析和调试。







🧠 哲学家就餐问题介绍




哲学家就餐问题是多线程编程中的一个经典死锁案例。它描述了五位哲学家围坐在圆桌旁,每人面前有一碗意面,每两人之间放有一把叉子。哲学家交替进行思考和进餐。
进餐规则:
- 哲学家必须同时拿到左手边和右手边的两把叉子才能进餐。
- 进餐完毕后,哲学家会放下两把叉子,继续思考。
核心矛盾:如果所有哲学家同时拿起自己左手边的叉子,那么每个人都会等待右手边的叉子被释放,从而导致死锁——所有人都无法进餐。


接下来,我们将通过代码模拟这个问题,并尝试解决它。






⚙️ 基础实现:使用互斥锁模拟



首先,我们使用互斥锁(mutex)来模拟叉子。每个叉子对应一个互斥锁,哲学家必须成功锁定(拿起)左右两把叉子才能进餐。

以下是哲学家线程的主要行为逻辑:


void philosopher(size_t id, mutex& leftFork, mutex& rightFork) {
for (size_t i = 0; i < 3; ++i) { // 每个哲学家进行3轮思考-进餐
think(id); // 思考一段时间
eat(id, leftFork, rightFork); // 尝试进餐
}
}



void eat(size_t id, mutex& leftFork, mutex& rightFork) {
leftFork.lock(); // 拿起左手边的叉子
rightFork.lock(); // 拿起右手边的叉子
// ... 模拟进餐 ...
rightFork.unlock(); // 放下右手边的叉子
leftFork.unlock(); // 放下左手边的叉子
}


在主函数中,我们创建5个哲学家线程和5个叉子(互斥锁),并为每个哲学家分配其左右叉子。


运行风险:如果所有哲学家同时执行,极有可能发生死锁。我们可以通过在所有线程开始进食前插入一个统一的延迟来人为触发这种死锁条件。
// 在主线程中,启动所有哲学家线程前
sleep_for(milliseconds(5000)); // 等待5秒,让所有线程准备就绪



这样,所有哲学家线程会几乎同时尝试获取叉子,从而暴露出死锁问题。






🚫 解决死锁方案一:引入许可证(忙等待)






为了防止死锁,一个思路是限制同时尝试进餐的哲学家数量。例如,只允许最多4位哲学家同时尝试拿叉子。我们引入一个“许可证”计数器来实现这个限制。




实现逻辑:
- 设置初始许可证数量为4(比哲学家总数少1)。
- 哲学家在尝试拿叉子前,必须先获得一个许可证。
- 如果没有许可证可用,哲学家必须等待。

以下是“等待许可证”函数的初步实现,它采用“忙等待”策略,即不断循环检查:




void waitForPermission(size_t& permits, mutex& permitsLock) {
while (true) {
permitsLock.lock();
if (permits > 0) {
permits--; // 获取一个许可证
permitsLock.unlock();
break; // 成功获得,退出循环
}
permitsLock.unlock();
// 忙等待:未获得许可证,休眠一小段时间后重试
sleep_for(milliseconds(10));
}
}



缺点:忙等待会持续消耗CPU资源,效率低下。哲学家线程在等待时会不断被唤醒和休眠。



✅ 解决死锁方案二:使用条件变量
为了更高效地等待,我们使用条件变量。条件变量允许线程在某个条件不满足时主动进入休眠状态,并在条件可能满足时被其他线程唤醒。
我们使用 condition_variable_any 和 mutex 配合工作。关键工具是 lock_guard,它能自动管理互斥锁的上锁与解锁。




改进后的 waitForPermission 函数:






void waitForPermission(size_t& permits, mutex& m, condition_variable_any& cv) {
lock_guard<mutex> lg(m); // 构造时上锁,析构时自动解锁
// 使用lambda表达式作为等待条件
cv.wait(m, [&permits] { return permits > 0; });
permits--; // 获得许可证
// lock_guard 超出作用域,自动解锁m
}

对应的 grantPermission 函数:



void grantPermission(size_t& permits, mutex& m, condition_variable_any& cv) {
lock_guard<mutex> lg(m);
permits++; // 释放一个许可证
if (permits == 1) { // 如果许可证从0变为1,可能有线程在等待
cv.notify_all(); // 通知所有等待的线程
}
}



工作流程:
- 哲学家线程调用
waitForPermission。如果permits > 0,则立即减少许可并继续。 - 如果
permits == 0,cv.wait()会解锁互斥锁m并使线程休眠。 - 当有哲学家进餐完毕,调用
grantPermission增加许可,并调用cv.notify_all()唤醒所有等待的线程。 - 被唤醒的线程会重新获取锁并检查条件,成功者获得许可证。
这种方式避免了忙等待,让线程在等待期间完全不消耗CPU资源。



🚦 解决方案三:使用信号量




条件变量的模式非常通用,以至于可以被封装成一个更高级的工具——信号量。信号量维护一个计数器,提供了 wait(获取资源)和 signal(释放资源)两种原子操作。


在C++标准库中并没有直接提供信号量,但我们可以很容易地利用 mutex 和 condition_variable_any 实现它,或者使用课程提供的封装类。




使用信号量简化哲学家问题:
// 声明一个初始值为4的信号量
semaphore permits(4);



// 在哲学家线程中,替换之前的 waitForPermission 和 grantPermission
void philosopher(size_t id, mutex& leftFork, mutex& rightFork, semaphore& permits) {
for (size_t i = 0; i < 3; ++i) {
think(id);
permits.wait(); // 等待并获得一个许可证(信号量内部操作)
eat(id, leftFork, rightFork);
permits.signal(); // 释放许可证
}
}
信号量的 wait() 和 signal() 方法内部已经封装了锁和条件变量的逻辑,使得代码更加简洁清晰。互斥锁本质上就是初始值为1的信号量。







📚 本节课总结




在本节课中,我们一起学习了:



- 哲学家就餐问题:一个经典的多线程死锁场景。
- 互斥锁:用于保护共享资源(叉子),但直接使用可能导致死锁。
- 许可证模式:通过限制并发数量来预防死锁。
- 忙等待的缺点:低效且消耗CPU。
- 条件变量:与互斥锁配合,让线程能高效等待条件成熟,并通过
lock_guard自动管理锁的生命周期。 - 信号量:一个更高级的同步原语,基于条件变量封装,用于管理一组资源的访问许可,极大简化了代码。


通过从基础实现到高级同步原语的演进,我们掌握了解决复杂线程同步问题的多种工具和思路。在实际编程中,应根据具体场景选择最合适的方法。

P13:第12讲 互斥量、条件变量和信号量复习 🧵
在本节课中,我们将复习多线程编程中的三个核心同步原语:互斥量、条件变量和信号量。我们将通过分析期中考试中的典型问题,并结合具体的代码示例,来深入理解它们的工作原理、使用场景以及如何避免常见的并发陷阱。









期中考试回顾与要点




期中考试已经结束,总体结果令人满意。有几个题目设计得比较有挑战性,特别是关于文件系统和进程间通信的部分。以下是对一些关键问题的总结。

关于管道与进程间通信


在涉及管道和进程间通信的题目中,一个常见的难点是处理输出的“去交织”问题。核心挑战在于,数据流是动态到达的,你无法在开始传输前收集所有数据。一个可行的解决方案是使用管道,但需要注意管道缓冲区有限,可能会被填满并导致写入阻塞。


关键代码模式示例:
// 创建管道
int pipefd[2];
pipe(pipefd);
// 在子进程中重定向标准输出/输入
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
close(pipefd[1]);

// 父进程读取子进程输出
此外,一个重要的细节是父进程必须使用 waitpid 等待子进程结束,否则可能导致 shell 提示符过早返回,程序行为异常。





关于互斥量的基础

上一节我们回顾了考试中的进程问题,本节中我们来看看线程同步的基础——互斥量。

互斥量是最基本的锁,用于保护临界区,防止多个线程同时访问共享数据,从而避免竞态条件。
- 锁定 (
lock): 线程尝试获取锁。如果锁未被占用,则获取成功并继续执行;如果已被占用,则线程阻塞等待。 - 解锁 (
unlock): 持有锁的线程释放锁,允许其他等待的线程获取它。
核心要点:
- 互斥量必须通过引用或指针在线程间共享。
- 只有加锁的线程才能解锁该锁。
- 应尽量缩短持有锁的时间,以减少对其他线程的阻塞。
- 使用
lock_guard可以自动管理锁的生命周期,避免忘记解锁。

lock_guard 示例:
std::mutex mtx;
{
std::lock_guard<std::mutex> lg(mtx); // 构造函数中加锁
// ... 操作共享数据 ...
} // 离开作用域,析构函数自动解锁
关于条件变量

仅使用互斥量时,线程间通信效率较低。条件变量允许线程在某个条件不满足时主动等待,并在条件可能满足时接收通知,从而避免忙等待。





条件变量总是与一个互斥量配合使用。其基本工作流程为:
- 线程获取关联的互斥锁。
- 检查条件。如果条件不满足,则在该条件变量上
wait。wait操作会原子性地释放互斥锁并使线程休眠。 - 当其他线程更改了条件并调用
notify_one或notify_all时,等待的线程被唤醒,并尝试重新获取互斥锁。 - 线程再次检查条件(通常使用
while循环),条件满足则继续执行。



使用 wait 的标准模式:
std::mutex mtx;
std::condition_variable cv;
bool ready = false;




// 等待线程
std::unique_lock<std::mutex> lock(mtx);
while(!ready) { // 必须用循环防止虚假唤醒
cv.wait(lock);
}
// ... 条件满足,执行操作 ...







// 通知线程
{
std::lock_guard<std::mutex> lg(mtx);
ready = true;
}
cv.notify_one(); // 或 notify_all()
C++11 提供了带谓词的 wait 重载,可以简化代码:
cv.wait(lock, []{ return ready; }); // 等价于上面的 while 循环





关于信号量

条件变量常用于实现更高级的同步模式,信号量就是其中一种。信号量维护一个计数器,表示可用“许可”的数量,提供了更直观的“许可”管理机制。

wait()(或P操作): 获取一个许可。如果许可数大于0,则减少计数并继续;如果为0,则阻塞等待。signal()(或V操作): 释放一个许可,增加计数。如果有线程在等待,则唤醒其中一个。


信号量初始化含义:
semaphore s(5);:初始有5个许可。semaphore s(1);:可作为互斥锁使用(二元信号量)。semaphore s(0);:初始无许可,用于线程间的等待-通知机制,类似于“门闩”。





以下是一个使用初始为0的信号量实现线程等待的示例:
semaphore done(0); // 初始许可为0
std::thread worker([&done] {
// ... 执行任务 ...
done.signal(); // 任务完成,发出信号
});

// 主线程等待工作线程完成
done.wait();
std::cout << "Worker finished!" << std::endl;
worker.join();


同步原语应用实例
理解了基本概念后,我们通过两个经典例子来看看如何应用这些原语解决实际问题。

生产者-消费者问题(循环缓冲区)
这是一个经典的同步问题:一个或多个生产者线程向固定大小的缓冲区放入数据,一个或多个消费者线程从缓冲区取出数据。需要保证缓冲区满时生产者等待,缓冲区空时消费者等待,且生产消费过程线程安全。



解决方案(使用两个信号量):
emptySlots: 表示缓冲区中空位的数量,初始值为缓冲区总大小N。fullSlots: 表示缓冲区中已存放数据的数量,初始值为0。- 还需要一个
mutex来保护对缓冲区指针(head,tail)的并发修改。
伪代码逻辑:
// 生产者
emptySlots.wait(); // 等待有空位
mtx.lock();
// 向缓冲区写入数据
mtx.unlock();
fullSlots.signal(); // 通知消费者有新数据

// 消费者
fullSlots.wait(); // 等待有数据
mtx.lock();
// 从缓冲区读取数据
mtx.unlock();
emptySlots.signal(); // 通知生产者有空位了
这个模式优雅地协调了生产者和消费者的速度,避免了竞态条件。
并行任务与限流
考虑一个需要查询多台远程服务器状态的任务(如“MythBuster”程序)。顺序查询会非常慢,因为大部分时间在等待网络I/O。使用多线程可以并行查询,但无限制地创建大量线程也可能导致资源耗尽。





解决方案(使用信号量限流):
我们可以使用一个信号量来限制同时运行的查询线程数量。









const int kMaxConcurrency = 8;
semaphore limiter(kMaxConcurrency); // 同时只允许8个线程“活跃”
std::vector<std::thread> workers;
std::mutex resultMtx; // 保护结果集合
for (const auto& server : servers) {
// 等待,直到有可用的“并发许可”
limiter.wait();
workers.emplace_back([&limiter, &resultMtx, server] {
// 执行网络查询(耗时操作)
auto result = queryServer(server);
{
std::lock_guard<std::mutex> lock(resultMtx);
// 安全地更新共享结果
}
// 查询结束,释放许可,允许新线程启动
limiter.signal();
});
}
// 等待所有工作线程结束
for (auto& t : workers) t.join();
这里,信号量 limiter 确保了无论有多少个服务器,同时进行查询的线程数都不会超过 kMaxConcurrency。注意,wait 和 signal 的调用巧妙地在线程开始和结束时管理着许可。


总结

本节课我们一起复习了多线程编程中三个核心的同步机制:
- 互斥量:提供了基础的互斥访问,是构建线程安全数据结构的基石。
- 条件变量:在线程间提供了更强大的等待/通知机制,常用于实现复杂的同步条件。
- 信号量:基于计数器,提供了更高级的“许可”控制抽象,非常适合用于资源池管理、生产者-消费者模式以及并发度限制等场景。
正确使用这些工具需要仔细分析程序中的共享数据和操作序列,识别潜在的竞态条件,并选择最合适的同步原语来封装它们。记住,目标是在保证正确性的前提下,尽可能减少锁的持有时间,提高程序的并发性能。
课程 P14:讲座13 冰激凌店模拟 🍦
在本节课中,我们将学习如何通过多线程编程来模拟一个冰激凌店的运营场景。我们将分析一个复杂的程序,它使用多种线程同步机制来协调顾客、店员、经理和收银员之间的交互。通过这个例子,我们将深入理解二进制锁、广义计数器、二进制会合、广义会合以及层次化构造等核心概念。
概述
我们将模拟一个冰激凌店的日常运营。在这个模型中,顾客进店购买一定数量的冰激凌甜筒。每个甜筒由一个专门的店员制作,制作完成后需要经理检查质量。只有合格的甜筒才能交给顾客,然后顾客去收银台结账。整个过程涉及多个线程(顾客、店员、经理、收银员)的并发执行与协调。



核心概念与结构
在深入代码之前,我们先了解程序中用到的几种关键同步机制。
1. 二进制锁(互斥锁)

上一节我们介绍了模拟场景,本节中我们来看看如何保证资源独占访问。二进制锁本质上是一个互斥锁(Mutex),它确保在同一时刻只有一个线程可以进入临界区或访问某个共享资源。

公式/代码描述:
std::mutex mtx;
mtx.lock();
// 临界区代码
mtx.unlock();


2. 广义计数器(信号量)

接下来,我们看看如何管理有限数量的资源。广义计数器通常通过信号量(Semaphore)实现。信号量维护一个计数器,可以原子性地进行增减操作,用于协调多个线程对有限资源的访问。
公式/代码描述:
sem_t sem;
sem_init(&sem, 0, initial_count); // 初始化
sem_wait(&sem); // P操作,申请资源
sem_post(&sem); // V操作,释放资源

3. 二进制会合

当两个线程需要精确协调彼此的执行顺序时,我们会用到二进制会合。这通常使用初始值为0的信号量来实现,一个线程等待(wait),另一个线程在完成特定任务后发出信号(post)。

公式/代码描述:
// 线程A
sem_wait(&rendezvous_sem); // 等待线程B的信号
// 继续执行...
// 线程B
// 完成某些工作
sem_post(&rendezvous_sem); // 通知线程A

4. 广义会合
广义会合用于一个线程需要等待多个其他线程都完成某项任务后才能继续执行的场景。这结合了二进制会合和计数器的思想。

公式/代码描述:
// 主线程生成N个工作线程
for (int i = 0; i < N; ++i) {
// 启动工作线程
}
// 主线程等待所有工作线程完成
for (int i = 0; i < N; ++i) {
sem_wait(&completion_sem[i]);
}
5. 层次化构造
在实际程序中,我们经常需要组合使用上述多种同步原语来构建更复杂的逻辑,这就是层次化构造。例如,可能先使用互斥锁保护一个共享队列,再使用信号量来通知消费者线程有新数据到达。
程序结构解析


现在,让我们逐一分析模拟程序中的各个组成部分。
全局数据结构


程序定义了几个全局结构体来共享状态和进行线程间通信。



检查结构体 (inspection)
这个结构体用于店员和经理之间的交互。
struct inspection {
std::mutex available; // 二进制锁,确保一次只有一个店员与经理交互
sem_t requested; // 信号量:店员通知经理“我有一个甜筒待检查”
sem_t finished; // 信号量:经理通知店员“检查已完成”
bool passed; // 检查结果:甜筒是否合格
} inspection;

结账结构体 (checkout)
这个结构体用于顾客和收银员之间的交互。
struct checkout {
sem_t customer_waiting; // 信号量:通知收银员有顾客在等待
std::atomic<unsigned int> next_enqueue_position; // 原子计数器:下一个排队位置
sem_t customer_finished[MAX_CUSTOMERS]; // 信号量数组:每个顾客一个,用于通知结账完成
} checkout;
这里使用了 std::atomic<unsigned int> 来原子性地更新排队位置,避免了使用互斥锁。
顾客线程 (customer)
顾客线程模拟顾客的行为。
以下是顾客线程的主要步骤:
- 获取需求:确定需要购买的甜筒数量。
- 创建店员线程:为每个甜筒创建一个店员线程来制作。
- 浏览等待:调用
browse函数模拟在店内浏览。 - 等待完成:使用
join等待所有为其服务的店员线程结束,这意味着所有甜筒都已制作并检查合格。 - 排队结账:原子性地获取下一个排队位置 (
next_enqueue_position)。 - 通知收银员:通过
sem_post(&checkout.customer_waiting)通知收银员有顾客准备结账。 - 等待结账完成:在属于自己的信号量
customer_finished[position]上等待,直到收银员处理完毕。 - 离开:结账完成后,顾客线程结束。


browse 函数非常简单,只是让线程休眠一段随机时间。
店员线程 (clerk)

店员线程负责制作单个甜筒,并确保其通过经理检查。

以下是店员线程的主要循环逻辑:
bool success = false;
while (!success) {
make_cone(); // 制作甜筒,耗时
inspection.available.lock(); // 获取与经理交互的锁
sem_post(&inspection.requested); // 通知经理检查甜筒
sem_wait(&inspection.finished); // 等待经理检查完毕
success = inspection.passed; // 获取检查结果
inspection.available.unlock(); // 释放锁
if (!success) {
// 甜筒不合格,丢弃,循环继续制作下一个
}
}
// 甜筒合格,线程结束
make_cone 函数模拟制作甜筒所花费的时间。
经理线程 (manager)

经理线程负责检查店员提交的甜筒是否合格。
以下是经理线程的主要循环逻辑:
for (int approved = 0; approved < total_cones_needed; ) {
sem_wait(&inspection.requested); // 等待店员提交甜筒
inspect_cone(); // 检查甜筒,更新 inspection.passed
sem_post(&inspection.finished); // 通知店员检查结果
if (inspection.passed) {
approved++; // 合格甜筒计数增加
}
}
// 达到所需合格甜筒数量,经理下班
inspect_cone 函数模拟检查过程,并随机决定甜筒是否合格。



收银员线程 (cashier)

收银员线程负责按顾客排队的顺序为他们结账。
以下是收银员线程的主要逻辑:
for (unsigned int next_to_serve = 0; next_to_serve < num_customers; next_to_serve++) {
sem_wait(&checkout.customer_waiting); // 等待下一位顾客
// 为位置是 `next_to_serve` 的顾客结账
sem_post(&checkout.customer_finished[next_to_serve]); // 通知该顾客结账完成
}
// 所有顾客结账完毕,收银员下班
关键点在于,收银员严格按 next_to_serve 的顺序(0, 1, 2...)服务顾客,这保证了先排队的顾客先被服务。顾客通过原子计数器 next_enqueue_position 确定自己的排队位置,从而与收银员的顺序匹配。

主函数 (main)
主函数负责初始化全局数据、创建经理线程、收银员线程以及所有顾客线程,并最终等待所有线程结束。
以下是主函数的简化流程:
- 初始化信号量和互斥锁。
- 创建经理线程(传入需检查的甜筒总数)。
- 创建收银员线程(传入顾客总数)。
- 循环创建每个顾客线程(传入顾客ID和其需求的甜筒数)。
- 等待所有顾客线程结束 (
join)。 - 等待收银员和经理线程结束 (
join)。 - 程序退出。

经理和收银员线程在创建后立即开始运行,但会很快在各自的信号量上等待,直到被顾客或店员的活动唤醒。这种模式类似于“线程池”,线程预先创建好,等待任务分配,减少了动态创建线程的开销。
总结


本节课中我们一起学习了一个复杂但经典的多线程编程实例——冰激凌店模拟。我们深入分析了如何使用:
- 二进制锁(
std::mutex) 来保护经理与店员之间的独占交互。 - 信号量(
sem_t) 来实现线程间的通知与等待(二进制会合),例如店员-经理的检查流程和顾客-收银员的结账流程。 - 原子变量(
std::atomic) 来实现无锁的顺序排队。 - 线程连接(
join) 来实现广义会合,例如顾客等待所有店员线程完成。
通过将这个现实场景分解为多个并发执行的线程,并精心设计它们之间的同步机制,我们构建了一个能够正确、高效模拟店铺运营的程序。理解这个程序的设计思路和实现细节,对于掌握多线程编程中的核心挑战——同步与通信——至关重要。
课程 P15:网络基础介绍 🖧

在本节课中,我们将要学习计算机网络的基础知识,包括服务器与客户端的概念、端口与套接字的作用,以及如何编写一个简单的网络程序来实现通信。

概述



网络是将两台或多台计算机连接起来进行通信的技术。它类似于进程间的管道,但范围更广,可以实现不同计算机之间的数据交换。互联网和万维网都基于此原理工作。


我们将从基础概念开始,逐步学习如何创建一个服务器程序来监听连接,以及如何创建一个客户端程序来请求服务。








服务器与客户端




上一节我们介绍了网络的基本概念,本节中我们来看看网络通信中的两个核心角色:服务器和客户端。
服务器是一台等待其他计算机(客户端)发起连接的计算机。客户端则是主动向服务器发起连接请求的一方。例如,当你访问一个网站时,你的浏览器是客户端,而托管网站的计算机就是服务器。



服务器必须提前设置并运行,才能响应客户端的连接请求。一个服务器可以同时处理多个客户端的连接。








端口与套接字




理解了服务器和客户端的关系后,我们来看看它们是如何具体建立连接的。这需要通过端口和套接字来实现。

套接字是一个用于标识网络连接的整数,范围通常是0到65535。你可以将其理解为一个虚拟的进程ID,它代表了一个特定的网络连接端点。

端口号是套接字的具体编号。服务器会监听一个特定的端口,等待客户端连接。当客户端想要连接服务器时,它需要知道服务器的IP地址和端口号。


以下是查看当前计算机上正在监听的端口的命令示例:
netstat -plnt
端口号分为几个范围:
- 0-1023:系统端口,通常用于标准服务(如HTTP的80端口,SSH的22端口)。
- 1024-49151:注册端口,可供用户程序申请使用。
- 49152-65535:动态或私有端口,可供临时使用。

两台计算机不能在同一端口上同时运行监听服务。




编写时间服务器

现在我们已经了解了端口和套接字,让我们动手实践,编写一个简单的服务器程序。这个服务器将监听客户端的连接,并在连接建立后,向客户端发送当前的格林威治标准时间。
以下是创建服务器套接字并进入监听循环的核心代码框架:
int server = createServerSocket(portNumber);
while (true) {
int client = accept(server, nullptr, nullptr); // 等待客户端连接
publishTime(client); // 向客户端发送时间
close(client); // 关闭客户端连接
}
createServerSocket 函数封装了底层的系统调用,用于创建和绑定服务器套接字。accept 函数会阻塞,直到有客户端连接进来。


处理时间数据


在 publishTime 函数中,我们需要获取当前时间并将其格式化为字符串发送给客户端。
我们使用 time 函数获取自1970年1月1日(Unix纪元)以来的秒数,这是一个 time_t 类型的整数。然后使用 gmtime 函数将其转换为包含年、月、日等详细信息的 struct tm 结构体。最后,使用 strftime 函数将时间格式化为字符串。
需要注意的是,标准的 gmtime 函数返回一个指向静态内存的指针,这在多线程环境下是不安全的。因此,在编写多线程服务器时,应使用其线程安全版本 gmtime_r。


以下是获取并格式化时间的代码示例:
time_t rawtime;
time(&rawtime);
struct tm tm;
gmtime_r(&rawtime, &tm); // 线程安全版本
char timeStr[128];
strftime(timeStr, sizeof(timeStr), "%c", &tm);
// 然后将 timeStr 发送给客户端
使用 Socket++ 库简化操作






上一节我们手动处理了数据的写入和连接关闭,过程稍显繁琐。本节中我们来看看如何使用 Socket++ 库来简化网络编程。




Socket++ 库提供了更高级的抽象,可以像操作C++标准流(如 cout)一样操作网络连接,并自动管理缓冲区和资源释放。



使用 Socket++ 重写时间发送逻辑的代码如下:
#include <socket++/sockstream.h>
// ... 创建 client 套接字 ...
sockbuf sb(client);
iosockstream ss(&sb);
ss << timeStr << endl;
// 退出作用域时,sockbuf的析构函数会自动关闭client
这使得代码更加简洁易读。




引入多线程提升性能



我们之前编写的服务器一次只能处理一个客户端请求,效率很低。本节我们将利用多线程技术来提升服务器的并发处理能力。

我们可以使用线程池。当有新的客户端连接时,服务器主线程迅速接受连接,然后将处理该客户端请求的任务(如 publishTime)提交给线程池中的一个空闲线程去执行。这样,主线程就可以立即返回去接受下一个连接,而多个客户端请求可以被并行处理。
以下是使用线程池调度任务的核心代码:
ThreadPool pool(4); // 创建包含4个工作线程的线程池
while (true) {
int client = accept(server, nullptr, nullptr);
pool.schedule([client] {
publishTime(client);
close(client);
});
}
在下一个作业中,你将有机会自己实现一个线程池。
编写时间客户端


服务器准备就绪后,我们需要一个客户端来测试它。编写客户端相对更简单。



客户端的核心任务是连接到指定服务器的指定端口,然后读取服务器发送回来的数据。


以下是客户端代码的核心部分:
int clientSocket = createClientSocket(serverHost, portNumber);
sockbuf sb(clientSocket);
iosockstream ss(&sb);
string timeline;
getline(ss, timeline); // 读取服务器发来的一行数据
cout << timeline << endl;
createClientSocket 函数负责建立到服务器的连接。之后,我们同样可以使用 Socket++ 库来方便地读取数据。





探索 HTTP 协议
我们构建了自定义协议的时间服务器。在现实世界中,最常用的应用层协议是 HTTP(超文本传输协议)。本节我们通过一个简单工具来直观感受HTTP。
我们可以使用 telnet 命令手动模拟一个HTTP客户端。telnet 可以建立到任何TCP服务器的纯文本连接。



例如,连接到谷歌的Web服务器(端口80)并请求主页:
telnet google.com 80
连接成功后,手动输入HTTP请求:
GET / HTTP/1.1
Host: google.com
(输入两个空行结束请求头)
服务器将返回谷歌首页的HTML代码。这直观地展示了浏览器与Web服务器之间的通信本质。

总结


本节课中我们一起学习了计算机网络的基础知识。我们从服务器与客户端模型入手,理解了端口和套接字作为通信端点的作用。我们实践了如何编写一个多线程的时间服务器,以及与之配套的客户端程序,并使用了 Socket++ 库来简化网络I/O操作。最后,我们通过 telnet 工具窥探了HTTP协议的工作方式,为后续学习更复杂的网络应用打下了基础。

网络编程的核心在于理解连接是如何建立、数据是如何在套接字之间可靠传输的。掌握了这些基础,你就能构建出各种网络应用程序。

🕸️ 课程 P16:第15讲 网络与客户端




在本节课中,我们将继续深入学习网络编程,特别是如何构建一个功能更完善的网络客户端。我们将从回顾一个简单的客户端开始,逐步构建一个类似 wget 的命令行工具,用于从网络下载内容。最后,我们将探讨如何创建一个能够处理特定请求(如拼字游戏单词查找)并返回结构化数据(JSON格式)的服务器。

🔍 回顾:简单的网络客户端

上一节我们介绍了如何创建一个最基本的客户端套接字来连接服务器并读取数据。其核心步骤是:创建套接字、建立连接、设置数据流、读取服务器响应并打印。
本节中,我们将在此基础上进行扩展,构建一个可以向服务器发送特定请求并获取更多信息的客户端。


📥 构建 wget 功能

wget 是 Linux 系统中的一个常用命令,用于从指定的 URL 下载文件。例如,输入 wget google.com 会将 Google 首页下载到本地的 index.html 文件中。
我们的目标是实现类似的功能:接收一个 URL,解析其组成部分,连接到对应的主机,发送 HTTP 请求,获取响应数据,并保存到文件中。

解析 URL
首先,我们需要将 URL 拆解为主机名和路径两部分。例如,对于 URL http://web.stanford.edu/class/cs110:
- 主机名是
web.stanford.edu - 路径是
/class/cs110



如果 URL 以 http:// 开头,我们需要去掉这个前缀。然后,寻找第一个斜杠 / 来分隔主机和路径。如果没有指定路径,则默认路径为 /。




以下是解析 URL 的核心思路,用伪代码表示:
pair<string, string> parseURL(string url) {
string host, path;
// 1. 如果以 "http://" 开头,则去掉它
// 2. 查找第一个 '/' 的位置
// 3. '/' 之前的部分是 host,之后的部分是 path
// 4. 如果没找到 '/',则 path 设为 "/"
return make_pair(host, path);
}



发起请求与获取内容

解析出主机和路径后,我们需要连接到主机(通常是 80 端口),并发送一个格式正确的 HTTP GET 请求。

一个基本的 HTTP GET 请求格式如下:
GET [路径] HTTP/1.0\r\n
Host: [主机名]\r\n
\r\n
请注意,行尾必须是 \r\n(回车换行),而不仅仅是 \n。发送请求后,需要调用 flush 操作确保数据立即发送出去。




处理服务器响应





服务器返回的响应分为两部分:响应头和响应体(数据)。
响应头包含元信息(如内容类型、是否压缩等),每一行是一个头部信息,以空行(\r\n\r\n)结束。
我们暂时不关心具体的头部内容,因此可以连续读取行,直到遇到空行,这表示头部结束,之后的内容就是我们要保存的数据体。


保存到文件
获取数据体后,我们需要将其写入本地文件。文件名可以从路径中提取(例如,路径 /class/cs110/index.html 的文件名是 index.html)。如果路径以 / 结尾或为空,则使用默认文件名 index.html。


保存数据的核心流程是:打开一个文件输出流,从网络流中循环读取数据块,并写入文件,直到读取完毕。
🧩 示例:拼字游戏单词查找服务器
接下来,我们将看一个更复杂的例子:构建一个服务器,它接收包含字母的请求,调用一个现有的“拼字游戏单词查找”程序,并将结果以 JSON 格式返回给客户端。这演示了如何将传统程序“网络服务化”。
服务器架构概述


服务器的主要工作流程如下:
- 创建服务器套接字并监听指定端口。
- 使用线程池来并发处理多个客户端连接,提高效率。
- 对于每个连接,解析客户端请求中的字母参数。
- 使用缓存(一个映射表)存储之前查询过的结果,避免重复计算。
- 如果缓存中已有结果,直接返回;如果没有,则通过子进程调用本地的单词查找程序。
- 将程序输出的单词列表、处理时间等信息,组装成 JSON 格式的响应。
- 按照 HTTP 协议规范,将 JSON 数据发送回客户端。





关键技术点



以下是实现过程中涉及的几个关键概念:



- 线程池与互斥锁:为了高效处理并发请求,我们使用线程池。当多个线程可能同时读写共享的缓存时,必须使用互斥锁来保护数据,防止出现竞态条件。加锁的范围应尽可能小,以减小性能影响。
- 子进程调用:服务器通过创建子进程来运行现有的单词查找程序,并捕获其输出。这使用了我们之前学过的进程创建与管理技术。需要注意的是,在多线程环境中调用
fork需要格外小心。 - JSON 响应构建:JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,易于人阅读和机器解析。我们的服务器需要手动构建类似下面的 JSON 字符串:
{ "elapsed": 0.04, "cached": false, "words": ["word1", "word2", "..."] } - HTTP 响应格式:服务器返回给客户端(如浏览器)的数据必须遵循 HTTP 协议。一个成功的响应通常如下所示:
其中,状态码HTTP/1.1 200 OK\r\n Content-Type: application/json\r\n Content-Length: [数据长度]\r\n \r\n [JSON 数据体]200表示成功,Content-Type告诉客户端数据的格式,Content-Length指明数据体的大小。

从浏览器访问



完成服务器后,我们不仅可以通过 telnet 或自定义客户端访问,还可以直接从 网页浏览器 访问。例如,在浏览器地址栏输入 http://myth59:12345/abcde。
更进一步,可以编写一个包含 JavaScript 的 HTML 页面,使用 fetch API 向我们的服务器请求数据,并动态更新网页内容,从而创建一个完整的交互式网页应用。





📚 总结

本节课我们一起学习了网络编程的更多实践内容。

我们首先构建了一个增强版的网络客户端,实现了类似 wget 的下载功能,深入了解了 HTTP 请求的发送与响应的解析。

接着,我们探索了如何构建一个能够提供特定网络服务的服务器。这个服务器综合运用了多线程、进程控制、缓存机制和网络协议等知识,将本地程序的功能通过 HTTP 和 JSON 接口暴露出来,使其能够被远程客户端(包括网页浏览器)调用。




通过这些例子,我们可以看到,网络编程的核心在于定义清晰的通信协议(如 HTTP)和数据格式(如 JSON),并在客户端和服务器端正确地实现它们。

网络系统调用教程 P17:第16讲 🌐

在本节课中,我们将学习网络编程中的核心系统调用,特别是如何通过套接字(socket)实现不同计算机之间的通信。我们将从解析主机名开始,逐步深入到创建客户端和服务器套接字的具体步骤。
主机名解析与IP地址获取 🔍
上一节我们讨论了多线程与多进程的交互。本节中,我们来看看网络编程的基础:如何将人类可读的主机名(如 www.facebook.com)转换为计算机可识别的IP地址。
gethostbyname 函数
gethostbyname 函数用于根据主机名获取其对应的IP地址信息。它接收一个主机名字符串,并返回一个 struct hostent 结构体指针。
struct hostent *gethostbyname(const char *name);
如果解析失败,该函数返回 NULL。失败原因可能是DNS服务器故障、网络问题或主机名不存在。

struct hostent 结构体

该结构体包含了主机的详细信息。以下是其核心字段:


h_name: 指向官方主机名的指针。h_aliases: 一个指向字符串指针数组的指针,表示该主机的别名列表。列表以NULL指针结束。h_addrtype: 地址类型,例如AF_INET代表IPv4地址。h_length: 地址长度(字节数)。对于IPv4,此值为4。h_addr_list: 一个指向网络地址指针数组的指针。对于IPv4,每个地址是一个struct in_addr。列表以NULL指针结束。


struct in_addr 是一个特殊的结构体,通常只包含一个成员 s_addr,它是一个表示IPv4地址的32位无符号整数。

字节序问题



计算机存储多字节数据(如32位IP地址)有两种顺序:小端序(低位字节在前)和大端序(高位字节在前)。为了保证网络通信的一致性,数据在发送前需要转换为网络字节序(大端序)。我们使用以下函数进行转换:


htons(): 将16位短整型(如端口号)从主机字节序转换为网络字节序。htonl(): 将32位长整型从主机字节序转换为网络字节序。ntohs(): 将16位短整型从网络字节序转换回主机字节序。ntohl(): 将32位长整型从网络字节序转换回主机字节序。





示例:解析主机名



以下是使用 gethostbyname 和 inet_ntop(将网络地址转换为点分十进制字符串)的代码示例:





#include <netdb.h>
#include <arpa/inet.h>
#include <stdio.h>







void resolve_hostname(const char* hostname) {
struct hostent *he = gethostbyname(hostname);
if (he == NULL) {
fprintf(stderr, "无法解析主机名: %s\n", hostname);
return;
}
printf("官方名称: %s\n", he->h_name);
// 遍历地址列表
struct in_addr **addr_list = (struct in_addr **)he->h_addr_list;
for (int i = 0; addr_list[i] != NULL; i++) {
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, addr_list[i], ip_str, sizeof(ip_str));
printf("IP地址 %d: %s\n", i+1, ip_str);
}
}




套接字地址结构 🏗️


在深入创建套接字之前,我们需要理解用于表示网络地址的通用结构。



通用套接字地址:struct sockaddr





这是一个通用的地址结构,用于在函数调用中传递地址信息。其定义如下:


struct sockaddr {
sa_family_t sa_family; // 地址族(如 AF_INET, AF_INET6)
char sa_data[14]; // 协议地址
};



互联网套接字地址(IPv4):struct sockaddr_in




这是实际用于IPv4通信的地址结构。它包含具体的地址信息。

struct sockaddr_in {
sa_family_t sin_family; // 地址族,必须为 AF_INET
in_port_t sin_port; // 端口号(网络字节序)
struct in_addr sin_addr; // IPv4地址
unsigned char sin_zero[8]; // 填充字段,通常设为0
};
类型转换
由于像 connect、bind 这样的系统调用接收通用的 struct sockaddr* 指针,我们在传递 struct sockaddr_in 地址时需要进行强制类型转换。

struct sockaddr_in server_addr;
// ... 填充 server_addr ...
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
系统调用内部通过检查 sa_family 字段来判断具体的地址类型并进行相应处理。

创建客户端套接字 🖥️➡️🌐


客户端套接字用于主动发起与远程服务器的连接。
创建客户端套接字主要分为以下几步:
- 解析服务器主机名:使用
gethostbyname获取服务器的IP地址。 - 创建套接字描述符:使用
socket()系统调用创建一个新的套接字。 - 填充服务器地址结构:创建一个
struct sockaddr_in,填入服务器IP、端口,并确保字节序正确。 - 发起连接:使用
connect()系统调用尝试连接到服务器。 - 错误处理:如果任何步骤失败,需要妥善关闭已打开的套接字描述符。
以下是核心代码框架:

int create_client_socket(const char* hostname, int port) {
// 1. 解析主机名
struct hostent *he = gethostbyname(hostname);
if (he == NULL) return -1;
// 2. 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) return -1;
// 3. 填充服务器地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(port); // 转换端口字节序
// 复制IP地址,注意h_addr_list是指针的指针
server_addr.sin_addr = *((struct in_addr *)he->h_addr_list[0]);
// 4. 发起连接
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
close(sockfd); // 5. 连接失败,关闭套接字
return -1;
}
// 连接成功,返回套接字描述符
return sockfd;
}


注意:调用此函数成功获取套接字后,使用者负责在通信结束时调用 close() 关闭它。




创建服务器套接字 🌐➡️🖥️
服务器套接字用于在本地监听,等待客户端的连接请求。

创建服务器套接字主要分为以下几步:



- 创建套接字描述符:使用
socket()系统调用。 - 填充本地地址结构:创建一个
struct sockaddr_in。通常将IP地址设置为INADDR_ANY(0.0.0.0),表示监听本机所有网络接口。端口设置为要监听的端口。 - 绑定地址:使用
bind()系统调用将套接字与特定的IP地址和端口绑定。 - 开始监听:使用
listen()系统调用,将套接字置于被动监听模式,准备接受连接。 - 错误处理:如果绑定或监听失败,需要关闭套接字。

以下是核心代码框架:

int create_server_socket(int port, int backlog) {
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) return -1;
// 2. 填充本地地址(允许任何接口连接)
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有IP
server_addr.sin_port = htons(port); // 设置监听端口
// 3. 绑定地址
if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
close(sockfd);
return -1;
}
// 4. 开始监听
// backlog参数定义了等待连接队列的最大长度
if (listen(sockfd, backlog) < 0) {
close(sockfd);
return -1;
}
// 监听成功,返回服务器套接字描述符
return sockfd;
}

关键点说明:
INADDR_ANY:一个特殊地址,表示服务器愿意接受来自任何网络接口(如以太网、Wi-Fi)的连接。listen()的backlog参数:指定了内核为此套接字排队的最大未完成连接数。超过此数量的连接请求可能会被拒绝。

服务器套接字创建成功后,可以使用 accept() 系统调用来接受具体的客户端连接,该调用会返回一个用于与客户端通信的新套接字描述符。原始的服务器套接字继续用于监听新的连接请求。

总结 📚

本节课中我们一起学习了网络系统调用的核心内容:
- 主机名解析:使用
gethostbyname将域名转换为IP地址,并理解了struct hostent结构体和网络字节序的概念。 - 套接字地址结构:了解了通用的
struct sockaddr和具体的struct sockaddr_in(IPv4)结构体,以及它们之间的类型转换。 - 客户端套接字:掌握了创建客户端连接的流程:解析主机名 -> 创建套接字 -> 填充地址 -> 调用
connect()。 - 服务器套接字:掌握了创建服务器监听端口的流程:创建套接字 -> 填充地址(常使用
INADDR_ANY) -> 调用bind()-> 调用listen()。
理解这些基础系统调用是进行更高级网络编程(如构建HTTP服务器、聊天程序等)的基石。记住,网络编程涉及许多细节和边界情况,充分的测试和错误处理至关重要。
🕸️ 课程 P18:第17讲 网络代理



在本节课中,我们将要学习网络代理的基本概念、工作原理以及如何实现一个具备缓存、黑名单和代理链功能的网络代理服务器。课程内容将围绕一个具体的编程作业展开,帮助你理解代理服务器在客户端和真实服务器之间扮演的中介角色。
概述
网络代理是一个位于网页浏览器和目标网站之间的服务器。浏览器不直接请求目标网站,而是将请求发送给代理服务器,由代理服务器转发请求并返回响应。使用代理有多种原因,例如屏蔽特定网站、缓存内容以节省带宽、匿名化请求或修改传输的数据(如翻转图片)。

什么是网页代理?





网页代理充当浏览器与互联网之间的中介。当浏览器配置使用代理后,其所有网页请求都将首先发送到代理服务器。

以下是使用代理的几个常见原因:
- 访问控制:阻止对特定域名(如社交媒体或特定国家网站)的访问。
- 内容过滤:阻止特定类型文件(如大型视频或压缩包)的下载。
- 匿名化:隐藏客户端的真实IP地址,增强隐私保护(例如Tor网络)。
- 内容修改:在传输过程中修改网页内容,例如翻转所有图片。
- 缓存:存储经常访问的网页副本,减少重复请求的带宽消耗和延迟。



代理作业详解




接下来,我们将深入分析你要实现的网络代理作业。该作业分为几个循序渐进的版本。





第一部分:实现顺序代理

首先,你需要实现一个基本的顺序代理,它能处理HTTP请求并转发。


核心任务包括:
- 支持HTTP方法:你需要处理
GET、HEAD和PUT请求。GET:获取资源。HEAD:与GET类似,但只返回响应头,不返回正文。PUT:向服务器上传数据。你需要从客户端请求中读取负载(payload),并转发给目标服务器。
- 修改请求头:代理需要在转发的请求中添加特定的头部信息。
X-Forwarded-Proto:声明客户端与代理之间使用的协议(例如http)。X-Forwarded-For:记录请求经过的代理IP地址链。如果请求中已存在此头部,你需要追加当前代理的IP地址。

一个 GET 请求示例如下:
GET /path HTTP/1.1
Host: www.example.com
你的代理需要将其转发给 www.example.com 的80端口。
作业提供了 HTTPRequest 和 HTTPResponse 等类来帮助你解析和构建请求。你主要需要在 request-handler.h/.cc 文件中实现 handleGETRequest, handlePUTRequest 等方法。
第二部分:实现黑名单功能


代理应能根据配置文件阻止对特定网站的访问。




实现步骤如下:
- 读取
blocked-domains.txt文件,其中包含需要屏蔽的域名模式(支持正则表达式)。 - 当收到请求时,检查目标主机是否在黑名单中。
- 如果匹配,则直接向客户端返回
403 Forbidden状态码的响应,而不是转发请求。


黑名单检查功能已提供,你主要需要将其集成到请求处理流程中。


第三部分:实现缓存功能



缓存可以存储服务器的响应,当同一请求再次发生时,直接返回缓存内容,无需访问远程服务器。


缓存逻辑如下:
- 检查缓存:收到
GET或HEAD请求后,首先检查本地缓存中是否有该请求的副本。 - 使用缓存:如果存在有效缓存,则直接将其返回给客户端。
- 获取并缓存:如果无缓存,则转发请求。收到响应后,检查响应头(如
Cache-Control)判断是否允许缓存。若允许,则将响应存入缓存。 PUT请求不应被缓存。





作业提供了 HTTPCache 类来管理缓存文件。你需要在请求处理程序中调用其接口。注意,缓存文件通常存储在用户主目录的隐藏文件夹中(例如 .proxy_cache_myth64)。
第四部分:使代理并发(线程安全)





为了使代理能同时处理多个客户端请求,你需要引入线程池。
关键改造点:
- 线程池:使用已有的
ThreadPool类(作业提供),将每个 incoming 的连接请求作为任务提交到池中执行。 - 请求调度器:实现一个调度器,负责接收连接并将其分发给线程池。
- 缓存线程安全:当多个线程可能同时读写缓存时,需要保证线程安全。重点是防止对同一资源(如同一个URL的请求)的并发访问。
- 为此,你需要维护一个包含 997个互斥锁 的数组。
- 对每个请求,计算其哈希值,然后对997取模,根据结果选择对应的互斥锁进行加锁。
- 这确保了只有真正请求同一资源的线程才会相互阻塞,不同资源的请求可以并行处理。
第五部分:支持代理链
代理可以配置为将请求转发给另一个代理,而不是直接访问目标服务器,从而形成代理链。
实现要点:
- 配置上游代理:通过
--proxy-server命令行参数指定上游代理的地址和端口。 - 转发请求:你的代理将请求转发给上游代理,而不是原始目标服务器。
- 防止循环:代理链可能形成循环(A->B->A)。为了防止这种情况,需要利用
X-Forwarded-For头部。- 在转发前,检查目标上游代理的IP是否已经存在于当前请求的
X-Forwarded-For头部列表中。 - 如果存在,说明出现了循环,应立即向客户端返回
504 Gateway Timeout错误。
- 在转发前,检查目标上游代理的IP是否已经存在于当前请求的




测试与调试建议

在开发过程中,请注意以下事项:
- 使用HTTP网站测试:大多数现代网站使用HTTPS。你的基础代理可能无法处理HTTPS(需要实现
CONNECT方法)。建议使用example.com、time.com等仍支持HTTP的网站进行测试。 - 配置浏览器:建议使用Firefox,并手动配置代理设置指向你的代理服务器(如
myth64.stanford.edu:端口号)。测试完毕后,记得关闭代理设置。 - 清除缓存:在测试缓存功能时,频繁使用
--clear-cache参数启动代理,或手动删除缓存目录,以避免旧缓存干扰测试结果。 - 使用Telnet调试:你可以使用
telnet工具直接向代理服务器发送原始的HTTP请求,观察其返回,这有助于调试。telnet myth64 19419 GET / HTTP/1.1 Host: www.example.com - VPN连接:若在校外,需要通过斯坦福VPN连接才能访问
myth机器。




总结
本节课我们一起学习了网络代理的核心概念。我们了解到代理作为中间层,可以用于流量转发、访问控制、内容缓存和匿名化。通过分解的作业任务,我们逐步实践了如何构建一个支持顺序处理、黑名单、缓存、多线程并发以及代理链的完整代理服务器。这个项目综合运用了网络编程、并发控制和系统设计等多方面知识,是本学期所学技能的一次重要整合。


附:关于最终作业 MapReduce 的简要预告
在接下来的课程中,我们将介绍最后一个作业:MapReduce。这是一个用于大规模数据处理的分布式编程模型,由Google推广。它将任务分为两个主要阶段:
- Map阶段:将输入数据拆分并分发到多台工作机器上进行并行处理,生成一系列中间键值对。
- Reduce阶段:将Map阶段产生的、经过排序/分组的中间结果进行汇总,产生最终的输出。

MapReduce作业将需要你运用网络通信、多线程和多进程协调的知识,是贯穿本学期主题的综合性项目。我们将在下节课详细展开。




课程 P19:第18讲 MapReduce 🗺️➡️📊
在本节课中,我们将学习 MapReduce 算法的核心概念、工作原理,并通过一个具体的单词计数示例来理解其处理流程。我们还将了解如何将其应用于分布式计算环境,并初步探讨相关编程作业的框架。



概述





MapReduce 是一种用于处理大规模数据集的编程模型和算法,最初由 Google 提出。它的核心思想是将复杂的计算任务分解为两个主要阶段:映射(Map) 和 归约(Reduce),从而能够高效地在成百上千台服务器上并行处理数据。本节课我们将解析其工作原理,并通过一个实例演示完整的处理流程。






MapReduce 核心流程


上一节我们介绍了 MapReduce 的基本概念,本节中我们来看看其标准工作流程。整个过程通常包含以下几个步骤:


- 映射(Map):将输入数据分割成多个片段,由不同的工作节点(或线程)并行处理。每个映射器处理一个数据片段,并生成一组中间键值对。
- 在单词计数例子中,映射器读取文本,为每个单词输出
(单词, 1)这样的键值对。 - 代码示例(Python映射器逻辑):
for word in line.split(): print(f"{word.lower()} 1")
- 在单词计数例子中,映射器读取文本,为每个单词输出




- 洗牌与排序(Shuffle & Sort):系统将所有映射器生成的中间结果按照键(如单词)进行收集和排序,确保相同键的数据被发送到同一个归约器。

- 归约(Reduce):每个归约器接收属于特定键的所有值,执行汇总操作(如求和、求平均等),并生成最终的输出结果。
- 在单词计数例子中,归约器对同一个单词的所有
1进行求和。 - 公式描述:对于键
k,其最终计数为count(k) = sum(v1, v2, ..., vn),其中每个v都是1。
- 在单词计数例子中,归约器对同一个单词的所有


这个流程使得处理海量数据成为可能,并且对单点故障具有很好的容错性。





单词计数示例详解


现在,我们通过一个具体的例子,将上述流程串联起来。假设我们要统计一段文本中各个单词的出现次数。

以下是处理步骤:


-
映射阶段:输入文本被分割。映射程序读取文本,每遇到一个单词,就输出一行“单词 1”。
- 例如,句子 “hello world hello” 经过映射后,会生成:
hello 1 world 1 hello 1
- 例如,句子 “hello world hello” 经过映射后,会生成:
-
排序阶段:将映射器的所有输出收集起来,并按单词进行排序。
- 排序后得到:
hello 1 hello 1 world 1
- 排序后得到:




- 归约阶段:归约程序读取已排序的数据。每当遇到一个新的单词,就开始对它的所有计数值(都是1)进行累加,直到下一个单词出现。
- 处理 “hello” 时,累加两个1,得到2。
- 处理 “world” 时,累加一个1,得到1。
- 最终输出:
hello 2 world 1



通过命令行工具,我们可以模拟这个过程:
# 1. 映射
cat input.txt | ./word_count_mapper > mapped_output.txt
# 2. 排序
sort mapped_output.txt > sorted_output.txt
# 3. 归约
./word_count_reducer < sorted_output.txt > final_output.txt





分布式实现与作业框架






上一节我们看了单机上的流程,本节中我们来看看如何在分布式环境(如Myth集群)中实现MapReduce。关键在于利用共享文件系统进行任务协调和数据交换。





以下是作业中你需要完成的核心任务概览:






- 任务一:理解代码框架。仔细阅读提供的服务器端(MRM)、工作节点端(Mapper/Reducer)的C++代码,理解它们如何通过SSH通信和文件系统交互。
- 任务二:实现多线程映射。修改
spawnMappers函数,使用线程池来并发启动多个映射任务,提高处理效率。你需要在此处正确添加锁机制,以保护共享资源。 - 任务三:实现中间文件哈希分发。修改映射过程,使其不仅生成一个
.mapped文件,而是根据单词的哈希值将结果分散到多个文件中(例如00001.00028.mapped)。这为后续的并行归约做准备。哈希函数可以通过hash<string> hasher; size_t hashValue = hasher(word) % numBuckets;实现。 - 任务四:实现归约阶段。这是最开放的部分。你需要编写归约器,使其能够:
- 收集所有属于同一哈希桶的中间文件(如所有以
.00028.mapped结尾的文件)。 - 对这些文件中的内容进行排序和按键分组。
- 调用
word_count_reducer(或使用C++实现相同逻辑)对每个单词的计数进行汇总。 - 将最终结果写入输出文件,并清理临时文件。
- 收集所有属于同一哈希桶的中间文件(如所有以


整个作业利用了Myth机器共享文件系统的特性,使得不同机器上的进程可以通过读写特定路径的文件来交换数据,从而模拟了分布式计算。



总结

本节课中我们一起学习了 MapReduce 这一强大的分布式计算模型。我们从其核心的 Map 和 Reduce 两阶段流程出发,通过一个详细的单词计数示例,逐步理解了数据如何从原始输入被映射为键值对,经过排序洗牌,最终被归约为汇总结果的过程。
我们还探讨了如何将这一模型应用到实际的分布式编程作业中,包括使用线程池并发执行任务、通过哈希函数分发数据以实现负载均衡,以及协调多个工作节点完成最终计算。理解这个框架,不仅有助于完成当前作业,也为将来处理大规模数据处理系统打下了坚实的基础。
CS110 课程 P2:文件系统与 umask 详解 📁


在本节课中,我们将要学习 Unix 文件系统中的权限管理核心概念 umask,并复习作业一的相关背景知识。我们还将探讨如何使用系统调用进行文件操作,并编写一个简单的文件搜索程序。




概述:umask 与用户权限控制




上一节我们介绍了文件的基本权限。本节中我们来看看 umask(用户文件创建掩码)如何让用户控制新创建文件的默认权限。



umask 的核心是让用户控制文件的默认权限。它的作用不是让程序去设置各种权限,而是让用户声明:“当一个程序为我创建文件时,我不希望它给全世界读取权限。” 这由用户来控制。





在终端输入 umask 命令,它会显示当前的用户掩码。例如,输出 0077。这里的第一个 0 表示这是一个八进制数字。077 意味着用户(所有者)可以设置任何权限(读、写、执行),但组和其他人(非所有者)的写权限被屏蔽。

公式:新文件的最终权限 = 程序请求的权限 & (~umask)








umask 工作原理示例




以下是 umask 如何影响文件创建的示例。






- 默认情况:当
umask为0077时,touch命令尝试为新文件设置rw-rw-rw-(0666)权限。应用umask后,组和其他人的写权限被屏蔽,最终文件权限变为rw-------(0600)。$ umask 0077 $ touch test1.txt $ ls -l test1.txt -rw------- 1 user group 0 Sep 10 10:00 test1.txt







- 更改
umask:如果将umask改为0000,则程序请求的所有权限都会被允许。$ umask 0000 $ touch test2.txt $ ls -l test2.txt -rw-rw-rw- 1 user group 0 Sep 10 10:00 test2.txt




umask 是反转应用的:它屏蔽(置0)的位,表示不允许设置的权限。程序尝试设置的权限会与 umask 的反码进行按位与运算,得到最终权限。
文件权限的表示



文件权限分为三个部分:所有者(红色)、组(绿色)和其他人(蓝色)。每个部分由 r(读)、w(写)、x(执行)三个位表示。
例如,权限 rw-rw-rw- 对应的二进制是 110110110,转换为八进制就是 666。因此,chmod 666 file 命令会将文件权限设置为 rw-rw-rw-。

公式:权限八进制计算:r=4, w=2, x=1。将所有者、组、其他人的权限值分别相加即可。例如 rw-r--r-- = 4+2, 4, 4 = 644。



关于用户组
每个文件都有一个所有者和一个所属组。你可以使用 groups 命令查看你属于哪些组。使用 ls -l 命令时,输出中的第二列就是文件的所属组。

作业一:凯文·贝肯的六度分隔 🎬

上一节我们讨论了系统权限,本节中我们来看看本周的作业。作业一的目的是复习 CS106B 和 CS107 的编程技能,并练习 C++ 编程。
作业要求你实现“凯文·贝肯的六度分隔”游戏。程序通过两个大型数据库文件(演员文件和电影文件)查找任意两位演员之间的最短合作路径。

核心操作:
- 二分查找:数据库文件已排序,你需要使用
lower_bound算法进行高效查找。 - 广度优先搜索:使用队列(或列表)来寻找演员之间的最短连接路径。
- C++ Lambda 表达式:作业要求使用 Lambda 表达式为
lower_bound提供自定义比较函数。





C++ Lambda 表达式简介
Lambda 表达式是一种匿名函数,可以在代码中内联定义并作为参数传递。这在需要传递简短逻辑时非常方便。
基本语法:
[capture](parameters) -> return_type { function_body }
示例:一个将向量中每个元素加上某值的 Lambda。
vector<int> vec = {1, 2, 3};
int val = 12;
// Lambda 捕获了外部变量 val
for_each(vec.begin(), vec.end(), [val](int &x) { x += val; });





Lambda 的“捕获”功能是其强大之处,它允许函数访问并操作其定义作用域内的变量,而无需通过参数传递。







系统调用:低级文件 I/O

理解了高级任务后,我们回到文件系统基础。Unix 提供了 read 和 write 等系统调用来进行低级文件操作。






copy 程序示例:以下是一个简化版的文件复制程序,演示了 read 和 write 的基本用法。




#include <fcntl.h>
#include <unistd.h>
#define BUFSIZE 1024




int main() {
int infd = open("input.txt", O_RDONLY);
int outfd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
char buffer[BUFSIZE];
ssize_t bytesRead;
while ((bytesRead = read(infd, buffer, BUFSIZE)) > 0) {
ssize_t bytesWritten = 0;
while (bytesWritten < bytesRead) {
ssize_t result = write(outfd, buffer + bytesWritten, bytesRead - bytesWritten);
if (result == -1) { /* 处理错误 */ break; }
bytesWritten += result;
}
}
close(infd);
close(outfd);
return 0;
}
关键点:
read和write调用可能不会一次性读完或写完请求的所有字节,需要循环处理。O_TRUNC标志表示如果输出文件已存在,则先清空其内容。








实现 find 命令的核心:stat 与目录遍历



最后,我们探讨如何利用系统调用实现类似 find 的命令,这需要用到 stat/lstat 和目录遍历函数。



stat 与 lstat:
- 这两个系统调用用于获取文件(或目录)的元信息,并填充到一个
struct stat结构中。 - 区别在于对待符号链接:
lstat返回链接本身的信息,而stat返回链接指向的目标文件的信息。


实现思路:
- 使用
lstat检查给定路径是文件还是目录。 - 如果是目录,使用
opendir、readdir、closedir函数遍历其中的所有条目。 - 对于每个条目,递归调用搜索函数。
- 如果是普通文件,则与目标模式进行匹配。




代码框架:
void list_matches(const char *dirpath, const char *pattern) {
DIR *dir = opendir(dirpath);
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
// 跳过 "." 和 ".."
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
// 构建完整路径
char fullpath[PATH_MAX];
snprintf(fullpath, sizeof(fullpath), "%s/%s", dirpath, entry->d_name);
struct stat st;
lstat(fullpath, &st);
if (S_ISDIR(st.st_mode)) {
// 如果是目录,递归搜索
list_matches(fullpath, pattern);
} else if (S_ISREG(st.st_mode)) {
// 如果是普通文件,检查是否匹配模式
if (strcmp(entry->d_name, pattern) == 0) {
printf("%s\n", fullpath);
}
}
// 忽略符号链接等其他类型
}
closedir(dir);
}



总结



本节课中我们一起学习了:
umask的作用与工作原理,它允许用户控制新文件的默认权限。- 作业一的核心要求:使用二分查找、广度优先搜索和 C++ Lambda 表达式解决演员关联问题。
- 使用
read/write系统调用进行低级文件操作,并理解了它们可能需要循环处理。 - 使用
stat/lstat获取文件信息,并结合目录遍历函数实现了一个简单的文件搜索程序逻辑。


这些知识是深入理解 Unix 系统编程和完成后续作业的基础。
🖥️ 课程 P20:第19讲 系统设计原则
在本节课中,我们将学习计算机系统设计的核心原则。这些原则是构建大型、复杂且高效系统的基础,它们贯穿于我们整个学期的学习内容,并将指导你未来的系统编程实践。



📚 概述
系统设计原则旨在将庞大复杂的系统分解为更小、更易管理的部分,并定义这些部分之间如何交互。理解这些原则有助于我们设计出更健壮、更高效且更易维护的软件。
🔍 抽象


上一节我们介绍了课程的整体目标,本节中我们来看看第一个核心原则:抽象。
抽象是将程序的功能(接口)与其具体实现细节分离开来的过程。你无需关心内部如何运作,只需知道如何使用它。这是我们一直在做的事情。
以下是抽象的几个例子:
- 排序程序:你传入一个数组,它返回排序后的数组。你不需要知道它使用的是快速排序还是归并排序。
- 接口:
sort(array) - 实现:具体的排序算法(如
O(n log n)的算法)。
- 接口:
- 文件系统:使用
fstream或iostream进行读写,这抽象了底层的缓冲、字节读取等复杂操作。 - 进程:调用
fork()会创建一个新进程,你无需了解内核如何复制进程映像。 - 信号:你注册一个信号处理函数,当事件发生时内核会调用它。信号如何被捕获和传递的细节被抽象了。
- 线程:与进程类似,你使用线程库创建和管理线程,而不必关心其底层调度。
- HTTP:定义了数据如何在网络上传输(GET/POST请求),网页浏览器使用者无需关心底层的数据包细节。
抽象的主要挑战在于设计一个清晰、易用的接口。

🧩 模块化与分层

理解了抽象如何隐藏复杂性后,我们来看看如何管理复杂性本身。这就是模块化与分层。


模块化指将一个大系统分解为多个较小的、功能独立的子系统(模块)。分层是模块化的一种形式,指系统被组织成一系列层次,每一层为其上层提供服务,并调用其下层的服务。




模块化的例子:
- 复印机:涉及软件、机械工程、物理(激光、碳粉)、网络等多个独立模块,无人能精通所有细节。
- 智能手机:包含多种无线电(蜂窝、Wi-Fi、蓝牙、NFC)、多个摄像头、各种传感器(陀螺仪、加速度计)。每个模块由专门的团队开发。


分层的例子:
- 文件系统:
- 路径名层:处理
/usr/class/...或../等路径。 - 文件名层:管理人类可读的文件名。
- inode层:用数字(inode号)高效标识文件。
- 文件层:存储文件的实际内容(数据块)。
- 块层:管理数据在磁盘上的物理位置。
- 路径名层:处理
- 编译器工作流程:编译过程本身就是分层的绝佳示例。
- 预处理器:处理
#include和#define等指令,进行宏替换和文件包含。- 命令:
clang -E hello.cc
- 命令:
- 词法分析器:将源代码字符流分解为一系列标记(token),如关键字、标识符、运算符。
- 命令:
clang -Xclang -dump-tokens hello.cc
- 命令:
- 语法分析器:根据语法规则将标记组织成抽象语法树(AST)。
- 命令:
clang -Xclang -ast-dump hello.cc
- 命令:
- 语义分析器:检查AST是否符合语言规则(如类型检查)。
- 代码生成:将AST转换为目标机器码或汇编代码。
- 命令:
clang -S hello.cc(生成.s汇编文件) - 名称修饰:C++支持函数重载,编译器会修改函数名(如
cout变成_ZNSt3...)以在汇编级别区分。
- 命令:
- 预处理器:处理
- 计算机网络(TCP/IP模型):
- 应用层:HTTP, FTP - 应用程序使用的协议。
- 传输层:TCP - 确保数据包顺序和可靠传输。
- 网络层:IP - 负责将数据包路由到目标机器。
- 链路层:以太网 - 处理本地网络设备间的数据传输。
- 物理层:电缆、光纤 - 传输原始比特流。
- 工具示例:
traceroute命令可以显示数据包从你的计算机到目标服务器所经过的所有网络节点(路由跳数)。




分层和模块化使得构建、理解和维护复杂系统成为可能。





🏷️ 命名与名称解析


在分层系统中,不同层次之间需要一种方式来定位和访问资源,这就引出了命名与名称解析的原则。



命名是将人类友好的标识符映射到计算机内部标识符的过程。

名称解析的例子:
- 文件系统:将路径名(如
/home/user/file.txt)解析为 inode 号。 - 网络:将域名(如
google.com)解析为 IP 地址(如142.250.190.78)。 - URL:统一资源定位符,包含了协议、主机名、路径等信息,可解析为具体的网络资源和本地文件。
- 文件描述符:进程内部用一个简单的整数(如
3)来代表一个打开的文件,操作系统通过文件描述符表将其解析为内核中的文件对象。



核心思想是:人类擅长记忆名字,计算机擅长处理数字(地址)。 命名系统就是两者之间的桥梁。








💾 缓存

名称解析或数据访问如果每次都去最慢的存储介质查找,效率会很低。缓存原则通过将最近或频繁使用的数据副本保存在更快的存储介质中,来显著提升系统性能。

计算机存储层次结构(从快到慢,容量从小到大):
- 寄存器:CPU内部,速度极快,数量极少。
- L1/L2/L3缓存:集成在CPU芯片上或附近,速度快,容量小(KB~MB级)。
- 主内存(RAM):速度较慢,容量大(GB级),断电数据丢失。
- 固态硬盘/机械硬盘:速度很慢,容量很大(TB级),数据持久化。

缓存工作原理(以直接映射缓存为例):
假设主内存有32个地址(5位地址),缓存只有8个位置(3位索引)。缓存根据内存地址的低3位(索引)决定数据存放在缓存的哪个槽位。高2位作为“标签”用于区分映射到同一槽位的不同内存地址。
访问过程:
- CPU请求一个内存地址。
- 缓存控制器根据地址索引查找对应槽位。
- 缓存命中:如果槽位有效且标签匹配,则直接从缓存返回数据(极快)。
- 缓存未命中:如果标签不匹配或槽位为空,则需从主内存加载数据到该槽位,并更新标签,然后返回数据(较慢)。这可能导致缓存驱逐,即旧数据被新数据覆盖。
其他缓存示例:
- DNS缓存:存储最近查询过的域名到IP的映射。
- 网页缓存:浏览器或代理服务器存储访问过的网页副本。
- 数据库缓存:存储频繁查询的结果。
缓存的目标是最大化命中率,让数据尽可能待在高速存储中。




🎭 虚拟化

缓存优化了数据访问,而虚拟化原则则优化了资源本身的管理和使用。虚拟化让一个物理资源看起来像多个逻辑资源(一对一多),或者让多个物理资源看起来像一个逻辑资源(多对一)。
两种虚拟化类型:
- 聚合虚拟化(多对一):多个物理资源组合起来,呈现为一个更强大或更可靠的单一逻辑资源。
- RAID磁盘阵列:将多个物理硬盘组合,呈现为一个逻辑卷,可提升性能(并行读写)或可靠性(数据冗余)。
- Andrew文件系统:将分布在全球多台服务器上的存储空间,呈现为一个统一的文件系统视图。
- Web服务器负载均衡器:将用户请求分发到后端的多个服务器,但对用户来说就像在访问一个网站。
- 分区虚拟化(一对多):一个物理资源被分割成多个独立的逻辑资源。
- 虚拟内存:每个进程都认为自己独享整个内存地址空间,实际上物理内存被多个进程共享。
- 线程:一个进程的地址空间被划分给多个线程,每个线程有自己的栈。
- 虚拟机:通过VMware、VirtualBox等软件,一台物理计算机可以同时运行多个独立的操作系统实例。
虚拟化提高了硬件利用率、提供了隔离性并增强了系统的灵活性。

⚡ 并发
当系统中有多个虚拟化的执行单元(如进程、线程)时,它们如何协调工作?这就是并发原则要解决的问题。
并发涉及多个执行流同时或交替执行,以充分利用多核处理器或提高单核处理器的响应能力。
并发在CS110中的体现:
- 多进程:通过
fork()创建。 - 多线程:通过线程库创建。
- 信号与中断处理:主程序执行被异步事件打断。
- 多核处理器:真正的并行执行。
并发编程的挑战与语言支持:
- 竞态条件:多个执行流以不可预测的顺序访问共享数据,导致结果错误。需要通过互斥锁(mutex)、信号量等机制进行同步。
- Erlang语言:专为高并发设计,其“Actor模型”天然避免了共享内存,从而减少了竞态条件。
- JavaScript:传统上采用单线程事件循环模型来避免并发复杂性(现代JS通过Web Workers等也支持多线程)。


理解并发是编写高效、正确现代软件的关键。



🔄 客户端-服务器与请求/响应

最后,我们来审视系统中各个独立模块之间最基础的交互模式:客户端-服务器模型,其核心是请求与响应。
这种模式不仅限于网络,它广泛存在于系统各个层面的通信中。
请求/响应的例子:
- 网络通信:浏览器(客户端)向Web服务器发送HTTP GET请求,服务器返回HTML响应。
- MapReduce:主节点向工作节点分发Map或Reduce任务(请求),工作节点返回计算结果(响应)。
- 进程间通信:管道、套接字、共享内存等机制都遵循“一方发送请求,另一方处理并回复”的模式。
- 系统调用:应用程序(客户端)向操作系统内核(服务器)发起请求(如
read()),内核执行后返回结果。 - 远程文件系统:客户端程序向远程文件服务器请求文件数据。


这种清晰的请求/响应范式定义了模块间的边界和责任,是构建分布式和模块化系统的基石。

📝 总结

本节课我们一起学习了系统设计的七大核心原则:
- 抽象:分离接口与实现,简化复杂性。
- 模块化与分层:分解大系统,定义清晰的层次结构。
- 命名与名称解析:建立人类标识符与机器地址的映射。
- 缓存:利用局部性原理,用快速存储加速访问。
- 虚拟化:灵活地聚合或分割物理资源。
- 并发:管理多个同时执行的逻辑流。
- 客户端-服务器与请求/响应:定义模块间交互的基本模式。

这些原则相互关联,共同构成了我们本学期所学的文件系统、进程、线程、网络编程等知识的理论基础。理解它们,将帮助你在未来的课程(如CS140操作系统、CS143编译器、CS144计算机网络)和工程项目中,更好地设计、分析和构建复杂的软件系统。
课程 P21:第20讲 非阻塞I/O 🚀
在本节课中,我们将学习非阻塞I/O的概念。我们将探讨它与传统阻塞I/O的区别,了解其工作原理,并通过示例理解为何以及如何在特定场景下使用它来提升程序效率。
概述
到目前为止,我们讨论的I/O操作通常是阻塞的。这意味着当程序执行读写操作时,系统调用会一直等待,直到收到响应或数据才返回。这可能导致程序在等待I/O时无所事事,浪费了宝贵的CPU时间。
本节我们将介绍非阻塞I/O。在这种模式下,I/O调用会立即返回,无论数据是否就绪。如果数据未就绪,调用会返回一个特定的错误码,而不是让程序无限期等待。这允许程序在等待I/O的同时,继续执行其他有用的工作。
I/O密集型与CPU密集型

在深入非阻塞I/O之前,我们需要理解两个关键概念:I/O密集型和CPU密集型。
- I/O密集型:指程序花费大量时间等待输入/输出操作完成(如网络请求、磁盘读写)。在此期间,CPU可能处于空闲状态。
- CPU密集型:指程序花费大量时间进行计算和处理,CPU持续处于忙碌状态。

一个程序可能在某些阶段是I/O密集型的,在另一些阶段是CPU密集型的。理解这一点有助于我们选择合适的并发模型来优化程序性能。


快速与慢速系统调用
系统调用也可以根据其等待行为进行分类。

- 快速系统调用:调用会立即返回,不依赖于外部资源。例如,获取主机名的系统调用
gethostname()。// 示例:快速系统调用 gethostname(hostname, size); - 慢速系统调用:调用可能无限期阻塞,直到某个外部事件发生。例如,从网络套接字读取数据
read(),或等待子进程结束waitpid()。// 示例:可能阻塞的慢速系统调用 bytes_read = read(socket_fd, buffer, size);



我们之前通过多线程来处理慢速系统调用,使得一个线程被阻塞时,其他线程可以继续工作。非阻塞I/O提供了另一种解决方案。





非阻塞I/O的工作原理



非阻塞I/O的核心思想是:让慢速系统调用不再阻塞。

我们可以通过系统调用(如 fcntl)将一个文件描述符(如套接字)设置为非阻塞模式。


// 将文件描述符 fd 设置为非阻塞模式
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);



设置之后,对该描述符的 read, write, accept 等调用行为会发生变化:
- 如果数据已就绪(或连接已到达),调用正常返回数据。
- 如果数据未就绪,调用立即返回-1,并将全局变量
errno设置为EAGAIN或EWOULDBLOCK(表示“本应阻塞”)。



程序需要检查返回值:
- 若返回值
> 0:成功读取/写入了数据。 - 若返回值
== 0:通常表示到达文件末尾(对端关闭连接)。 - 若返回值
== -1:需要检查errno。- 如果
errno == EAGAIN或errno == EWOULDBLOCK,表示暂时没有数据,应稍后重试。 - 否则,表示发生了真正的错误。
- 如果




示例:慢速字母服务器与客户端
为了演示阻塞与非阻塞的区别,我们来看一个“慢速字母服务器”的例子。

上一节我们介绍了系统调用的分类,本节中我们来看看一个具体的阻塞I/O例子及其问题。

服务器行为
服务器接受连接后,会以每次一个字母、每个字母间隔0.1秒的速度,向客户端发送26个英文字母。整个发送过程需要2.6秒,人为制造了I/O延迟。

初始的阻塞客户端
客户端使用阻塞模式读取数据,每次读取一个字节。




// 伪代码:阻塞客户端
while ((count = read(sock_fd, &ch, 1)) > 0) {
printf(“%c”, ch);
}




运行结果:客户端耗时约2.6秒完成读取,期间主线程被 read 调用完全阻塞,无法进行任何其他操作。


改进:非阻塞客户端

现在,我们将客户端修改为非阻塞模式。



以下是修改的核心逻辑:


// 伪代码:非阻塞客户端核心循环
set_nonblocking(sock_fd); // 将套接字设置为非阻塞



while (!done) {
count = read(sock_fd, &ch, 1);
if (count > 0) {
printf(“%c”, ch); // 成功读取到数据,处理它
if (ch == ‘Z’) done = true; // 假设收到‘Z’表示结束
} else if (count == 0) {
// 对端关闭连接
done = true;
} else { // count == -1
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有数据可读,可以在这里执行其他任务
do_other_work();
// 然后继续循环,尝试再次读取
} else {
// 发生真实错误,处理错误
handle_real_error();
done = true;
}
}
}




运行结果:客户端仍然需要2.6秒来接收所有字母,但在这段时间内,do_other_work() 函数被调用了数百万次。这意味着主线程在等待数据的间隙,可以高效地处理其他任务,而不是被挂起。


关键点:非阻塞I/O将等待的“空闲时间”还给了程序本身,让程序有机会执行其他计算(CPU密集型工作),从而可能提高整体吞吐量。


事件驱动模型与 epoll

简单的非阻塞I/O循环(忙等待)有一个明显缺点:如果大部分时间都没有数据,循环会空转,消耗大量CPU资源,变成“忙等待”,这显然不高效。

为了解决这个问题,操作系统提供了更高级的机制,如Linux的 epoll。它允许程序同时监视多个文件描述符,并只在它们真正有事件(如可读、可写)发生时才被唤醒。


以下是使用 epoll 的基本步骤:


- 创建 epoll 实例:
epoll_create1(0)返回一个文件描述符。 - 注册感兴趣的事件:使用
epoll_ctl将需要监视的文件描述符(如监听套接字、客户端连接套接字)添加到 epoll 实例中,并指定关心的事件类型(如EPOLLIN可读)。 - 等待事件:调用
epoll_wait。这个调用会阻塞,直到一个或多个被监视的描述符上有事件发生,或者超时。 - 处理事件:
epoll_wait返回后,程序遍历发生的事件,执行相应的读/写/接受连接操作。


这种模式被称为事件驱动模型。它结合了非阻塞I/O的效率和多路复用的优雅,是现代高性能服务器(如Nginx、Node.js)的基石。
// 伪代码:epoll 事件循环框架
int epoll_fd = epoll_create1(0);
// ... 将 server_fd 等加入 epoll 监视 ...
while (1) {
int n_ready = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n_ready; i++) {
if (events[i].data.fd == server_fd) {
// 有新连接到达,接受它
accept_new_connection(server_fd, epoll_fd);
} else {
// 某个客户端连接有数据可读或可写
handle_client_event(events[i].data.fd, events[i].events);
}
}
}
使用 epoll 后,服务器在无事可做时会安静地阻塞在 epoll_wait 上,不消耗CPU。当事件发生时,内核会通知它,它再以非阻塞的方式处理这些事件。这完美避免了忙等待,同时保持了高并发处理能力。





总结
本节课中我们一起学习了非阻塞I/O的核心概念。
我们首先区分了I/O密集型和CPU密集型任务,以及快速和慢速系统调用。然后,我们探讨了通过 fcntl 设置非阻塞模式,使慢速调用立即返回的基本方法,并通过“慢速字母客户端”的例子对比了阻塞与非阻塞模式的行为差异。
最后,我们介绍了更高级的事件驱动模型和Linux的 epoll 机制。这种模型允许单个线程高效地管理成千上万的并发连接,是现代高性能网络编程的关键技术。
选择阻塞I/O、多线程还是事件驱动非阻塞I/O,取决于具体的应用场景、性能要求和系统平台。理解这些底层原理,将帮助你在未来构建更高效、更健壮的系统。
课程3:Unix v6 文件系统 📂


在本节课中,我们将学习 Unix 第六版(v6)文件系统。这是一个关于文件系统如何构建的经典案例研究。我们将了解其核心概念,包括磁盘结构、i节点、目录以及如何通过路径查找文件。课程最后,我们将通过三个具体示例来巩固理解。














概述:从内存到磁盘 💾








上一节我们介绍了计算机内存(RAM)可以被视为一个巨大的、可按字节寻址的数组。本节中,我们来看看持久化存储设备(如硬盘或SSD)是如何工作的。







与RAM不同,磁盘以块(或扇区)为单位进行读写。每个块的大小是固定的(例如512字节)。这意味着,即使你只想读取或写入一个字节,也必须操作整个512字节的块。







磁盘提供给操作系统的API非常简单,主要是读取和写入指定编号的块。



为了简化讨论,除非特别说明,我们将“块”和“扇区”视为同义词。





磁盘布局 🗺️
Unix v6文件系统将磁盘空间组织成几个关键部分。下图展示了其基本结构:


以下是磁盘布局的详细说明:



- 块0:引导块。此块包含启动计算机所需的代码,我们无需深入关注。
- 块1:超级块。此块包含关于文件系统本身的信息(如总大小、空闲块数量等),供操作系统使用。
- 元数据区。此区域存储文件的元数据,即描述文件自身的信息。
- 数据区。此区域存储文件的实际内容(有效载荷)。






整个磁盘空间由元数据区和数据区共享。你可以将其类比为堆分配器:一部分空间用于存储数据本身,另一部分用于管理这些数据的元数据。

文件与数据块 📄



文件的实际内容存储在数据区的512字节块中。





- 一个文件至少占用一个块(512字节)。
- 如果一个文件大于512字节,它会占用多个块。
- 这些块在磁盘上不需要连续存储。文件可以分散在不同的位置。


例如:
- 一个32字节的文件占用1个块(但只使用了其中的32字节)。
- 一个1028字节的文件占用3个块(前两个块完全使用,第三个块只使用了一部分)。


i节点:文件的元数据核心 🔍

为了追踪文件内容存储在哪些块中,我们需要元数据。在Unix v6中,每个文件(或目录)都有一个对应的i节点。

i节点是一个32字节的数据结构,它存储了关于文件的几乎所有信息:


- 文件类型(普通文件、目录、链接)
- 文件大小(字节数)
- 文件权限
- 时间戳(创建、修改时间)
- 块地址列表:最多8个块编号,指向存储文件内容的磁盘块。


i节点本身也存储在磁盘上,位于元数据区。每个512字节的磁盘块可以容纳 16个 i节点(因为 16 * 32 = 512)。



关键点:文件名并不存储在i节点中。i节点只通过一个数字(i节点号)来标识。文件名的管理由另一个机制处理,我们稍后会看到。







目录:文件名到i节点的映射 📁



人类使用文件名,而系统内部使用i节点号。目录就是连接这两者的桥梁。



目录本身是一种特殊类型的文件。它的内容不是普通数据,而是一个表格,其中每一行将一个文件名映射到一个i节点号。
在Unix v6中,目录文件的每一行是16字节:
- 14字节:文件名(不以空字符结尾)。
- 2字节:对应的i节点号(小端字节序)。





因此,一个512字节的块可以存储 512 / 16 = 32 个目录项。

操作系统将目录文件的内容隐藏起来,用户不能直接读取其原始字节。









路径解析:查找文件的过程 🧭







现在,我们可以理解如何通过路径(如 /users/class/cs110/assign1)找到文件了。








过程是迭代的:
- 从根目录
/开始。我们知道根目录的i节点号是固定的(例如1或2,取决于系统)。找到其i节点,进而找到存储其内容的磁盘块。 - 读取根目录的内容(即文件名-i节点号映射表),查找路径的第一个组成部分(例如
users)。找到其i节点号。 - 根据找到的i节点号,定位到
users目录的i节点,进而找到存储其内容的磁盘块。 - 读取
users目录的内容,查找下一个组成部分(例如class)。重复此过程。 - 最终,找到目标文件(如
assign1)的i节点号。通过该i节点的块地址列表,即可读取文件的实际内容。







这个过程就像沿着路径逐级打开文件夹,直到找到目标。













大文件与间接寻址 🐘







之前提到,i节点只能直接存储8个块地址。如果一个文件超过8个块(即 > 8 * 512 = 4096 字节),该怎么办?








Unix v6 使用间接寻址来解决这个问题。







单层间接寻址
当文件被标记为“大文件”时,i节点中的前7个块地址不再直接指向数据块,而是指向间接块。
- 每个间接块大小为512字节。
- 由于每个块地址需要2字节存储,一个间接块可以存储 512 / 2 = 256 个数据块地址。
- 这样,通过7个间接块,可以管理 7 * 256 = 1792 个数据块,加上原本可能有的直接块。








双层间接寻址
如果文件更大,i节点中的第8个块地址被用作双层间接块。
- 它指向一个块,这个块里存储的不是数据块地址,而是256个间接块的地址。
- 每个间接块又可以指向256个数据块。
- 因此,通过一个双层间接块,最多可以管理 256 * 256 = 65536 个数据块。



容量计算
综合来看,一个Unix v6文件的最大容量为:
- 直接/间接部分:7个间接块 * 256 块/间接块 = 1792 个数据块。
- 双层间接部分:1个双层间接块 * 256 间接块/双层间接块 * 256 块/间接块 = 65536 个数据块。
- 总计数据块:1792 + 65536 = 67328 个。
- 最大文件大小:67328 块 * 512 字节/块 ≈ 34 MB。



对于1970年代的标准来说,34MB已经非常大了。










实践示例 🛠️





上一节我们介绍了文件系统的核心原理,本节中我们通过三个具体例子来看看如何应用这些知识。






以下是查找和读取文件时需要遵循的步骤摘要:
- 解析路径:从根目录开始,逐级查找目录,将文件名转换为i节点号。
- 获取i节点:根据i节点号,在元数据区找到对应的i节点结构。
- 判断文件类型与大小:从i节点中读取文件类型和大小。
- 定位数据块:
- 如果文件是小文件(大小 ≤ 4096字节),直接使用i节点中的块地址列表。
- 如果文件是大文件(大小 > 4096字节):
- 前7个地址指向间接块。需要先读取间接块,从中获取实际的数据块地址列表。
- 第8个地址可能指向双层间接块,需要多一层查找。
- 读取数据:按照获得的块地址顺序,从数据区读取块内容。对于最后一个块,需要根据文件大小计算实际有效的字节数。

示例1:读取小文件 /local/files/fairytale.txt
假设文件大小为1057字节(< 4096字节)。
- 找到根目录
/的i节点(假设为1),读取其内容(块25)。 - 在根目录中查找
local,找到其i节点号为16。 - 读取i节点16,找到
local目录的内容块(块27和54)。在块27中找到files目录,其i节点号为31。 - 读取i节点31,找到
files目录的内容块(块32)。在块32中找到fairytale.txt,其i节点号为47。 - 读取i节点47。由于文件大小1057 < 4096,i节点中的地址(如80, 89, 87)直接指向数据块。
- 依次读取块80、89、87的内容。注意,最后一个块(87)可能未满,需根据文件大小计算有效数据范围。
示例2:读取中等文件 /medfile
假设文件大小为800,000字节(> 4096字节,但未使用双层间接块)。
- 通过根目录找到
medfile的i节点(例如16)。 - 读取i节点16。由于文件大小 > 4096,它被标记为大文件。其前7个地址(如26, 30...)现在指向间接块。
- 例如,读取第一个间接块(块26)。该块包含256个两字节的数据块地址(如80, 87, ...)。
- 按照顺序,读取地址80指向的数据块(文件开头),然后地址87指向的数据块,以此类推,处理完这256个块。
- 接着处理i节点中的下一个地址(如30),读取下一个间接块,获取下一批256个数据块地址,继续读取。
- 重复此过程,直到读取完文件的所有数据块。同样需要注意最后一个数据块的有效数据范围。

示例3:读取大文件 /bigfile
假设文件大小为18 MB(> 917,504字节,需要使用双层间接块)。
- 通过根目录找到
bigfile的i节点。 - 读取i节点。它是大文件。前7个地址按示例2的方式处理(单层间接)。
- 对于第8个地址(例如指向块30),它现在是一个双层间接块。
- 读取块30。它包含256个两字节的地址,每个地址指向一个间接块(例如第一个地址指向块87)。
- 读取第一个间接块(块87)。这个块本身又包含256个数据块地址。
- 按照块87中的地址列表,读取第一批256个数据块。
- 返回双层间接块(块30),读取下一个地址(例如指向块114),获取下一个间接块,再读取下一批256个数据块。
- 重复此过程,直到通过双层间接机制读取完文件的所有剩余数据。


总结 📚

本节课中,我们一起学习了Unix第六版文件系统的核心设计。




我们首先了解了磁盘的基本读写单位——块。然后,探讨了Unix v6如何将磁盘划分为引导块、超级块、元数据区和数据区。i节点作为32字节的元数据结构,是文件系统的核心,它存储了文件属性并管理其数据块地址。目录作为一种特殊文件,实现了文件名到i节点号的映射,使得路径解析成为可能。最后,我们深入研究了间接寻址机制,它通过单层和双层间接块,巧妙地扩展了单个文件的管理能力,使其最大可达到约34MB。

通过三个逐步深入的示例,我们实践了从路径解析到数据读取的完整流程。理解这套机制,是完成相关编程任务(如文件系统遍历器)的重要基础。请记住,这是一个特定历史时期的经典设计,现代文件系统更为复杂,但其核心思想——元数据与数据分离、通过索引进行高效查找——依然具有重要价值。



CS110 课程第四讲:文件系统数据结构、系统调用与多进程简介 🖥️

在本节课中,我们将学习操作系统如何通过数据结构管理文件和进程,理解系统调用的工作原理,并初步接触多进程编程的概念。
概述 📋

操作系统需要高效地管理计算机资源,特别是文件和正在运行的程序(进程)。本节课将探讨Linux系统用于跟踪这些资源的核心数据结构,解释用户程序如何通过系统调用安全地与操作系统交互,并介绍如何创建和管理多个并发执行的进程。





文件系统数据结构 📁

上一节我们提到了操作系统需要管理资源。本节中,我们来看看操作系统内部用于跟踪文件和进程的核心数据结构。

在Linux中,一个运行中的程序被称为进程。操作系统为每个进程维护一个进程控制块,其中包含了该进程的所有信息。这些进程控制块被组织在一个称为进程表的全局数据结构中。


进程控制块中的一个关键部分是描述符表。这是一个数据结构,用于跟踪该进程打开的所有“文件”。在Unix/Linux哲学中,许多资源(如网络连接、终端)都被抽象为文件。因此,描述符表不仅记录常规文件,也记录这些抽象资源。


每个进程在启动时,默认会获得三个文件描述符:
- 0: 标准输入 (
stdin) - 1: 标准输出 (
stdout) - 2: 标准错误 (
stderr)

这些描述符通常连接到用户的终端。我们使用文件描述符来执行read、write、open、close等操作。
数据结构层级

以下是文件描述符背后的数据结构层级关系:


- 描述符表: 每个进程独有,存储其打开文件的引用。
- 打开文件表: 系统全局共享。当描述符指向一个打开的文件时,它实际链接到一个打开文件表项。该表项存储了文件的访问模式(如只读、读写)和当前读写位置(游标)。
- 公式:
打开文件表项 = {模式, 游标, 引用计数, ...}
- 公式:
- v-node表: 系统全局共享。每个打开文件表项指向一个v-node(虚拟节点)。v-node是磁盘上文件元数据(存储在i-node中)的内存缓存副本,包含了文件类型、大小、权限等信息。
- 公式:
v-node ≈ 内存中的 i-node 缓存
- 公式:

这种层级结构允许多个进程共享同一个打开的文件(例如,共享终端输出),同时又能让每个进程维护自己独立的读写位置。

核心概念图示:
进程A描述符表[3] ---> 全局打开文件表项X ---> 全局v-node表项V (链接到磁盘i-node)
进程B描述符表[4] ---> 全局打开文件表项Y ---> 全局v-node表项V (链接到磁盘i-node)
(即使进程A和B打开同一个文件,它们也可能有独立的打开文件表项X和Y,从而拥有独立的游标。)


系统调用 🔧




上一节我们了解了操作系统内部的数据结构。本节中我们来看看用户程序如何通过系统调用与这些受保护的内核结构进行交互。






系统调用是程序与操作系统内核交互的接口,用于执行需要特权的操作,如文件操作、进程创建等。普通函数调用无法提供必要的安全隔离。






为什么需要系统调用?




用户程序运行在用户模式,只能访问自己的内存空间。而操作系统内核运行在内核模式,可以访问所有内存和硬件。系统调用是用户模式程序请求内核模式服务的安全桥梁。


系统调用如何工作?


系统调用的执行流程与普通函数调用不同:




- 准备参数: 将系统调用编号(例如,
open对应编号2)和参数放入指定的CPU寄存器。- 代码示例(概念性汇编):
mov rax, 2 ; 系统调用编号 (open) mov rdi, filename ; 第一个参数 (文件名指针) mov rsi, flags ; 第二个参数 (打开标志) mov rdx, mode ; 第三个参数 (文件模式)
- 代码示例(概念性汇编):
- 触发中断: 执行
syscall指令。这会触发一个软中断,将CPU控制权从用户模式切换到内核模式。 - 内核处理: 内核的中断处理程序根据寄存器中的编号识别请求,执行相应的内核函数(如真正的
open操作)。 - 返回结果: 内核将结果(成功则返回文件描述符,失败则返回-1)放入
rax寄存器,并切换回用户模式,程序从syscall指令之后继续执行。







错误处理:如果系统调用失败,内核除了在rax中返回-1,还会设置一个全局变量errno来指示具体的错误原因。








多进程编程简介 👥
前面我们讨论了单个进程如何运行。本节中,我们将探索如何让一个程序创建并管理多个同时执行的进程。


现代操作系统支持多任务处理,可以并发运行多个程序。每个运行中的程序都是一个进程,拥有唯一的进程ID。
创建新进程:fork系统调用
fork系统调用用于创建一个新的进程。新进程称为子进程,调用fork的进程称为父进程。



fork的独特之处在于:它只被调用一次,但会返回两次——分别在父进程和子进程中返回。

- 在父进程中,
fork返回新创建的子进程的PID。 - 在子进程中,
fork返回0。 - 如果
fork失败,则返回-1。




代码示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
pid_t pid = fork(); // 在这里,进程一分为二
if (pid == 0) {
// 子进程执行的代码
printf("Hello from child process! My PID is %d.\n", getpid());
} else if (pid > 0) {
// 父进程执行的代码
printf("Hello from parent process! My child‘s PID is %d.\n", pid);
} else {
// fork失败
perror("fork failed");
}
return 0;
}


fork的语义细节


- 继承与复制:子进程是父进程的副本,它获得父进程代码、数据段、堆栈和文件描述符表的副本。然而,现代操作系统使用写时复制技术进行优化,只有在任一进程试图修改数据时,才会真正复制内存页,这提高了效率。
- 执行流:
fork调用后,父进程和子进程都从fork调用之后的下一行代码开始并发执行。执行顺序由操作系统调度决定,是非确定性的。 - 独立性:父子进程有各自独立的地址空间。修改其中一个进程的变量,不会影响另一个。


一个fork示例:进程树







以下程序演示了多次调用fork如何创建一棵进程树:

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





int main() {
char trail[] = "ABCD";
for (int i = 0; trail[i] != ‘\0‘; i++) {
printf("%c", trail[i]);
pid_t pid = fork();
assert(pid >= 0);
}
printf("\n");
return 0;
}



这个程序会打印出类似A B B C C C C D D D D D D D的输出,但字母的顺序和换行符的位置每次运行都可能不同,这生动地展示了多进程执行的并发性和非确定性。




总结 🎯


本节课中我们一起学习了:






- 文件系统数据结构:了解了Linux如何通过进程控制块、描述符表、打开文件表和v-node表的多层结构来高效管理文件和进程资源。
- 系统调用:理解了系统调用作为用户程序与内核安全交互的机制,包括其工作原理(通过
syscall指令和寄存器传递参数)与普通函数调用的本质区别。 - 多进程编程基础:掌握了使用
fork系统调用创建新进程的方法,理解了父子进程的关系、fork的返回语义、写时复制机制以及多进程执行的并发性和非确定性。



这些概念是理解操作系统工作原理和进行系统级编程的基石。在接下来的课程中,我们将深入探讨如何协调多个进程(例如,让父进程等待子进程结束),以及更强大的进程间通信机制。






课程 P5:第五讲 - Fork、Waitpid 与 Execvp 🚀

在本节课中,我们将深入学习进程管理的核心概念:fork、waitpid 和 execvp。我们将探讨如何创建子进程、如何让父进程等待子进程完成,以及如何让子进程执行全新的程序。这些是构建更复杂应用(如Shell)的基础。



关于 Fork 的讨论与作业说明

上一节我们介绍了 fork 的基本用法。在深入新内容之前,我们先讨论一些相关话题和即将到来的作业。

有观点认为,Unix 将 fork 和 exec 结合是一种独特的设计。但也有人认为,fork 是上世纪70年代针对当时机器和程序的一种巧妙技巧,其现代实用性已经过时,甚至成为一种负担。作为操作系统教育的一部分,我们将其作为历史背景来学习,但你们仍然需要掌握它,尤其是在期中考试中。
关于文件系统的一个澄清:当两个进程各自调用 open 打开同一个文件时,系统会在打开文件表中创建两个独立的条目,每个都有自己独立的游标位置。只有在 fork 之后,子进程才会继承父进程的文件描述符,从而共享同一个打开文件表条目和游标。
接下来是作业说明。第二个作业(实际上是第一个涉及新知识的作业)关于文件系统。你们需要基于课堂上描述的 Unix V6 文件系统,编写代码来读取和写入磁盘镜像文件。


以下是作业的核心任务概述:
- 作业目标是能够定位、读取并写入磁盘镜像中的文件。
- 你将主要在四个文件中编写代码:
inode.c、file.c、directory.c和pathname.c。 - 建议按上述顺序完成。
- 作业使用 C 语言,不能使用 C++ 的 STL 容器(如
map,vector),需直接操作数组和结构体。

一个关键函数是 inode_index_lookup,它的作用是:给定一个文件对应的 inode 和该文件内的逻辑块号,返回该逻辑块在磁盘上的实际物理扇区号。
例如,一个大小为 180,000 字节的文件,逻辑块号 302 代表该文件第 302 个 512 字节的数据块。你的函数需要根据 inode 中的直接、间接指针信息,计算出这个数据块在磁盘上的实际位置。

作业中还需注意:
- 目录可能包含超过 32 个文件,需通用处理。
- 文件名最长 14 字符且不以空字符结尾,比较时不能使用
strcmp。 - 尽早开始,充分利用 Piazza 和办公时间寻求概念性帮助。


使用 Waitpid 控制进程执行顺序
在上一节的 fork 示例中,我们遇到了输出顺序不可预测的问题。本节我们将学习如何使用 waitpid 系统调用来让父进程等待子进程,从而控制执行流程。


waitpid 函数原型如下:
pid_t waitpid(pid_t pid, int *status, int options);
pid: 指定要等待的子进程ID。传入-1表示等待任何一个子进程。status: 指向整数的指针,用于获取子进程的退出状态。options: 通常设为0。- 返回值:成功时返回终止子进程的PID;失败时返回
-1。

waitpid 会暂停父进程的执行,直到指定的子进程状态发生变化(如终止)。同时,它也会完成对子进程的清理工作。







让我们看一个使用 waitpid 的示例程序 separate.c:




#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>




int main(int argc, char *argv[]) {
printf("Before.\n");
pid_t pid = fork();
printf("After.\n");
if (pid == 0) {
printf("I'm the child. Parent will wait up.\n");
return 110; // 子进程返回值
} else {
int status;
waitpid(pid, &status, 0); // 父进程等待特定的子进程
if (WIFEXITED(status)) {
printf("Child exited with status %d.\n", WEXITSTATUS(status));
} else {
printf("Child terminated abnormally.\n");
}
}
return 0;
}
在这个程序中,父进程一定会等待子进程结束后才检查其状态,因此“After”总是在子进程的输出之后打印,顺序变得可预测。




宏 WIFEXITED(status) 用于检查子进程是否正常退出,WEXITSTATUS(status) 则用于提取子进程的返回值。







多子进程管理与 Waitpid 进阶




上一节我们看到了父进程等待一个子进程的情况。本节我们来看看当存在多个子进程时,如何使用 waitpid 进行管理。








我们可以通过循环多次调用 fork 来创建多个子进程。为了等待所有子进程结束,我们可以在一个循环中调用 waitpid(-1, &status, 0),它会等待任意一个子进程结束,并返回该子进程的PID。



以下是一个创建8个子进程并等待它们全部结束的示例框架:
for (size_t i = 0; i < 8; ++i) {
if (fork() == 0) {
// 子进程:做自己的工作,然后退出
exit(110 + i); // 每个子进程返回不同的值
}
}
// 父进程:等待所有子进程
while (1) {
int status;
pid_t pid = waitpid(-1, &status, 0);
if (pid == -1) break; // 没有更多子进程了
if (WIFEXITED(status)) {
printf("Child %d exited with status %d.\n", pid, WEXITSTATUS(status));
}
}
由于 waitpid(-1, ...) 等待的是任意结束的子进程,因此打印出的子进程结束顺序可能与创建顺序不同。
如果想严格按照创建顺序等待子进程,则需要保存每个子进程的PID,然后依次对每个PID调用 waitpid。








引入 Execvp:运行新程序



到目前为止,我们使用 fork 创建的子进程运行的是与父进程相同的代码。但 fork 更常见的用途是:创建一个子进程,然后让该子进程去执行一个全新的、不同的程序。这正是 Shell 运行命令的方式。







实现这个功能需要另一个系统调用家族:exec。我们将重点学习 execvp。





execvp 函数原型如下:
int execvp(const char *file, char *const argv[]);
file: 要执行程序的路径(例如/bin/ls)。argv: 传递给新程序的参数数组,格式与main函数的argv相同。argv[0]通常是程序名。- 返回值:仅在发生错误时返回 -1。如果执行成功,该函数永不返回,因为当前进程的代码和数据已被新程序完全替换。







一个典型的模式是:fork 创建子进程,在子进程中调用 execvp 来运行新程序,而父进程则调用 waitpid 等待子进程(即新程序)结束。






实战:构建一个简易 Shell 🐚



结合我们刚学的 fork、waitpid 和 execvp,本节我们将构建一个非常简易的 Shell 原型 my_system。



这个简易 Shell 的工作流程是:
- 显示提示符,读取用户输入的命令。
fork出一个子进程。- 在子进程中,使用
execvp调用系统 Shell(如/bin/sh)来执行用户输入的命令。 - 在父进程中,使用
waitpid等待子进程结束,并获取其退出状态。




以下是核心函数 my_system 的实现框架:
int my_system(const char *command) {
pid_t pid = fork();
if (pid == 0) {
// 子进程
char *arguments[] = {"/bin/sh", "-c", (char *)command, NULL};
execvp(arguments[0], arguments);
// 如果 execvp 成功,不会执行到这里
fprintf(stderr, "Failed to invoke /bin/sh to execute the provided command.\n");
exit(0);
} else {
// 父进程
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
return WEXITSTATUS(status); // 返回子进程(即命令)的退出码
}
return -WTERMSIG(status); // 如果命令被信号终止,返回负的信号值
}
}
主函数则是一个循环,不断打印提示符、读取命令、调用 my_system 并显示返回值。





通过这个例子,你可以清晰地看到 fork、execvp 和 waitpid 如何协同工作,来执行外部程序并管理其生命周期,这正是真实 Shell 的底层机制。





课程总结







本节课中,我们一起深入学习了进程管理的核心操作:
fork:用于创建当前进程的副本(子进程)。waitpid:用于让父进程等待一个或全部子进程结束,并回收资源、获取其退出状态。这是避免竞争条件、控制执行顺序的关键。execvp:用于让一个进程“脱胎换骨”,停止执行当前代码,转而去执行磁盘上的另一个全新程序。它通常与fork配合使用。








我们还通过构建一个简易的 Shell 原型,看到了这三个系统调用如何在实际场景中协同工作。理解这些概念是进一步学习进程间通信、管道、信号以及构建复杂并发应用的基础。
CS110 课程笔记 P6:execvp, pipe, dup2, signals 🚀
在本节课中,我们将要学习多进程编程中的几个核心概念:如何使用 execvp 运行新程序,如何通过 pipe 和 dup2 在进程间建立通信管道,以及如何利用 signals 处理进程间的事件通知。这些是构建更复杂程序(如你自己的shell)的基础。
概述:多进程与进程间通信
上一节我们介绍了 fork 系统调用,它允许我们创建新的进程。本节中我们来看看如何让新进程执行不同的程序,以及如何让父子进程之间进行数据交换和事件通知。


我们将通过构建一个简单的 shell 程序来串联这些概念,它能运行命令、处理后台进程,并管理进程间的输入输出。






1. 使用 execvp 运行新程序 🔄




execvp 是 exec 函数家族的一员,它的作用是用另一个程序替换当前进程的内存映像。这意味着调用 execvp 后,原进程的代码就不再运行,转而执行新的程序。



核心公式:
int execvp(const char *file, char *const argv[]);
file: 要执行的程序名。argv: 传递给新程序的参数列表(类似于main函数的argv),必须以NULL指针结束。

通常,我们不会直接在父进程中调用 execvp,因为这会终止父进程。更常见的模式是:
- 父进程调用
fork()创建子进程。 - 在子进程中调用
execvp()来运行新程序。 - 父进程继续执行原有逻辑。

以下是一个简单示例 mysystem 函数,它模拟了系统调用 system() 的部分功能:



pid_t pid = fork();
if (pid == 0) {
// 子进程:运行 /bin/sh 来解释执行命令
char *args[] = {"/bin/sh", "-c", command, NULL};
execvp(args[0], args);
// 如果 execvp 成功,不会执行到这里
exit(1);
} else {
// 父进程:等待子进程结束
waitpid(pid, &status, 0);
}
在这个模式中,子进程被 /bin/sh(即shell程序)“吞噬”,并由它来执行用户输入的命令(如 ls)。







2. 实现一个简单的后台 Shell 🐚


基于 fork 和 execvp,我们可以构建一个能处理前台和后台命令的简易shell。


核心逻辑:
- 读取用户输入的命令。
- 如果命令以
&结尾,则标记为后台运行。 fork出子进程,在子进程中用execvp执行命令。- 在父进程中:
- 如果是前台命令,则调用
waitpid等待子进程结束,再显示下一个提示符。 - 如果是后台命令,则不等待,直接显示下一个提示符,让子进程在后台独立运行。
- 如果是前台命令,则调用

以下是关键代码片段:

// 解析命令,判断是否以 '&' 结尾
int run_in_background = is_background_command(args);
pid_t pid = fork();
if (pid == 0) {
// 子进程执行命令
execvp(args[0], args);
exit(1); // execvp 失败时才执行
} else if (pid > 0) {
// 父进程
if (!run_in_background) {
// 前台运行:等待子进程
waitpid(pid, &status, 0);
} else {
// 后台运行:打印进程ID后立即返回
printf("[%d] %s\n", pid, command);
}
}
这样,当用户输入 sleep 10 & 时,shell会立刻返回提示符,而 sleep 命令则在后台继续执行。
3. 使用 pipe 建立进程间通信管道 🚰



pipe 系统调用创建了一个单向通信通道,用于两个进程(通常是父子进程)间传递数据。它返回两个文件描述符(file descriptor):一个用于读取,一个用于写入。


核心公式:
int pipe(int fds[2]);
调用成功后:
fds[0]成为管道的读取端。fds[1]成为管道的写入端。- 写入
fds[1]的数据可以从fds[0]读取。

重要特性:调用 fork 后,子进程会继承父进程打开的文件描述符。因此,父子进程可以通过同一个管道进行通信。通常的用法是:
- 父进程关闭读取端 (
fds[0]),只保留写入端,向管道写数据。 - 子进程关闭写入端 (
fds[1]),只保留读取端,从管道读数据。

以下是一个简单的管道示例,父进程向子进程发送字符串:
int fds[2];
pipe(fds); // 创建管道

pid_t pid = fork();
if (pid == 0) {
// 子进程:关闭写入端,准备读取
close(fds[1]);
char buffer[128];
read(fds[0], buffer, sizeof(buffer));
printf("Child read: %s\n", buffer);
close(fds[0]);
exit(0);
} else {
// 父进程:关闭读取端,准备写入
close(fds[0]);
write(fds[1], "Hello from parent", 18);
close(fds[1]); // 关闭写入端表示数据发送完毕
waitpid(pid, NULL, 0); // 等待子进程
}
注意:read 调用会阻塞,直到有数据可读或管道写入端全部关闭。父进程关闭写入端 (fds[1]) 是通知子进程“数据已发送完”的关键。


4. 使用 dup2 重定向标准输入输出 🔀

dup2 系统调用用于复制一个文件描述符。最常见的用途是重定向进程的标准输入(STDIN_FILENO, 0)、标准输出(STDOUT_FILENO, 1)或标准错误(STDERR_FILENO, 2)。

核心公式:
int dup2(int oldfd, int newfd);
它使 newfd 成为 oldfd 的副本。如果 newfd 已经打开,会先将其关闭。


在管道通信中,我们常用 dup2 将子进程的标准输入重定向到管道的读取端。这样,子进程在执行时(例如运行 sort 命令),就会从管道读取数据,而不是从键盘输入。

以下是将管道读取端重定向为标准输入的典型用法:

// 假设在子进程中,fds[0]是管道的读取端
close(fds[1]); // 子进程不需要写入端
dup2(fds[0], STDIN_FILENO); // 将标准输入重定向到管道读取端
close(fds[0]); // 重定向后,原始的fds[0]可以关闭了
// 现在执行程序,例如 sort,它会从管道读取输入
execlp("sort", "sort", NULL);

5. 综合示例:创建子进程并传递数据 🧩



结合 pipe、fork、dup2 和 execvp,我们可以编写一个 subprocess 函数。该函数启动一个子进程(如 sort),并返回一个文件描述符给父进程。父进程向这个文件描述符写入数据,数据就会成为子进程的输入。
以下是 subprocess 函数的简化框架和 main 函数中的用法:
// subprocess_t 结构体,用于返回子进程信息
typedef struct {
pid_t pid; // 子进程ID
int supply_fd; // 父进程写入此fd,数据会供给子进程
} subprocess_t;
subprocess_t subprocess(char *command) {
int fds[2];
pipe(fds);
pid_t pid = fork();
if (pid == 0) {
// 子进程
close(fds[1]); // 关闭写入端
dup2(fds[0], STDIN_FILENO); // 重定向标准输入到管道
close(fds[0]);
// 执行命令
execlp("/bin/sh", "sh", "-c", command, NULL);
exit(1);
} else {
// 父进程
close(fds[0]); // 关闭读取端
subprocess_t sp = {pid, fds[1]};
return sp;
}
}


// 在 main 函数中使用
int main() {
// 启动 sort 命令作为子进程
subprocess_t sp = subprocess("/usr/bin/sort");
// 父进程向子进程供给数据
char *words[] = {"banana", "apple", "cherry"};
for (int i = 0; i < 3; i++) {
dprintf(sp.supply_fd, "%s\n", words[i]);
}
close(sp.supply_fd); // 关闭供给端,告知子进程输入结束
waitpid(sp.pid, &status, 0); // 等待子进程结束
return 0;
}
运行此程序,sort 子进程会收到单词列表,排序后输出到终端。父进程关闭 supply_fd 至关重要,它相当于在终端按下 Ctrl+D,告诉 sort 输入已结束,可以开始排序了。





6. 使用 signals 处理进程事件 📞

信号(Signal)是内核向进程发送的异步事件通知。例如,按下 Ctrl+C(发送 SIGINT)可以终止前台进程,子进程结束时会向父进程发送 SIGCHLD 信号。


我们可以为特定信号注册一个信号处理函数(signal handler),当信号发生时,该函数会被调用。
核心概念:
SIGCHLD:子进程状态改变(终止、停止、继续)时发送给父进程。signal(int signum, void (*handler)(int)):设置信号处理函数。
一个常见的用途是处理僵尸进程。如果父进程不调用 waitpid 回收已终止的子进程,子进程会变成“僵尸”。通过捕获 SIGCHLD 信号并在处理函数中调用 waitpid,可以及时清理子进程。
以下是一个“迪士尼乐园”的例子,演示了父进程在子进程(孩子们)玩耍时小睡,并在孩子回来时被唤醒:
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
int children_done = 0;
void reap_child(int sig) {
waitpid(-1, NULL, 0); // 回收任意一个已终止的子进程
children_done++;
}
int main() {
signal(SIGCHLD, reap_child); // 设置 SIGCHLD 的处理函数
for (int i = 0; i < 5; i++) {
if (fork() == 0) {
// 子进程:模拟玩耍时间
sleep(3 * (i + 1));
exit(0);
}
}
// 父进程:等待所有孩子回来
while (children_done < 5) {
printf("At least one child is playing, dad naps.\n");
sleep(5); // sleep 会被到来的 SIGCHLD 信号中断
printf("Dad wakes up!\n");
}
printf("All children back home. Let's leave!\n");
return 0;
}
在这个例子中,sleep 会被 SIGCHLD 信号中断,父进程每次被唤醒就检查是否有孩子回来。使用信号处理使得父进程无需主动轮询子进程状态。


注意:一些信号(如 SIGKILL, SIGSTOP)不能被捕获或忽略。在信号处理函数中应尽量只做简单操作,避免使用不可重入函数(如 printf),本例仅为演示。





总结 🎯


本节课中我们一起学习了多进程编程的核心工具链:
execvp:用于在子进程中加载并执行全新的程序。- Shell 实现:结合
fork和execvp,可以构建支持前后台任务管理的简单shell。 pipe:创建单向通信管道,是进程间通信(IPC)的基本方式之一。dup2:重定向文件描述符,常用于将管道的一端连接到进程的标准输入或输出。- 综合应用:通过
subprocess模式,可以灵活地启动子进程并控制其输入源。 signals:处理异步事件,如使用SIGCHLD信号高效地回收子进程资源。

掌握这些概念和系统调用,你就有能力编写出像 shell 一样可以创建、管理、并与多个进程交互的复杂程序。在接下来的作业中,你将有机会实践这些知识,构建更强大的工具。
课程 P7:第七讲 信号 🚦
在本节课中,我们将要学习操作系统中的信号机制。信号是进程间通信的一种基本方式,用于通知另一个进程某个事件已经发生。我们将通过编写代码示例,深入理解信号的工作原理、信号处理程序的编写,以及如何应对信号处理中可能出现的竞态条件。



概述



信号是一种进程间通信机制,允许一个进程通知另一个进程某个事件已经发生。它不传递具体数据,只传递一个信号编号。本节课我们将学习如何设置信号处理程序,处理子进程状态变化,并解决信号处理中的同步问题。










信号基础





上一节我们介绍了信号的基本概念。本节中我们来看看信号的具体工作机制。

信号本质上是一种通知机制。当一个进程需要通知另一个进程时,它可以发送一个特定的信号编号。接收信号的进程可以预先定义一个信号处理程序,这是一个函数,当信号到达时会被自动调用。




核心公式/代码:设置信号处理程序
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
// 例如,设置 SIGCHLD 信号的处理函数为 reap_child
signal(SIGCHLD, reap_child);




需要注意的是,信号处理程序无法接收除信号编号以外的其他信息。如果需要在进程间传递更多数据,必须借助共享内存、文件等其他机制。



处理子进程状态变化


在并发编程中,父进程经常需要知道其子进程的状态变化(如结束、停止)。SIGCHLD 信号就是为此设计的。

当父进程的一个子进程状态发生变化时,内核会向父进程发送 SIGCHLD 信号。我们可以在信号处理程序中调用 waitpid 系统调用来“收割”已结束的子进程,并获取其退出状态。



核心代码:在 SIGCHLD 处理程序中收割子进程
void reap_child(int sig) {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 成功收割一个子进程,处理其退出状态 status
num_children_done++;
}
}
上面的代码使用 while 循环和 WNOHANG 选项,确保即使多个子进程同时结束,也能全部被正确处理,而不会遗漏。









竞态条件与信号同步


上一节我们介绍了如何收割子进程。本节中我们来看看信号处理中一个常见的问题:竞态条件。



竞态条件是指当多个事件(或进程)几乎同时发生时,其执行顺序的不确定性可能导致程序结果不符合预期。在信号处理中,这尤其棘手。




考虑以下场景:父进程 fork 出子进程,并将其加入一个作业列表。子进程结束后,信号处理程序会将其从列表中移除。如果子进程结束得太快,可能在父进程将其加入列表之前,信号处理程序就已经试图移除它了。
示例:有问题的作业列表程序
// 父进程代码片段
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程:立即执行命令并退出
execvp(...);
}
sleep(1); // 父进程休眠1秒
printf("Job %d added to task list.\n", pid);
}
// SIGCHLD 处理程序会打印 “Job %d removed from task list.”
运行此程序,可能会看到“移除”打印在“添加”之前,这就是一个竞态条件。




使用信号阻塞实现同步
为了解决上述竞态条件,我们需要控制信号的接收时机。我们可以使用 信号集 和 信号掩码 来暂时阻塞特定的信号,直到我们准备好处理它们。
以下是实现同步的步骤:




- 初始化信号集:创建一个空的信号集。
- 添加信号到集合:将
SIGCHLD信号添加到该集合中。 - 阻塞信号:在关键代码段(如将子进程加入列表)执行前,阻塞该信号集中的信号。
- 解除阻塞:在关键代码段执行后,解除对信号的阻塞。所有在阻塞期间到达的
SIGCHLD信号会被递延,此时才会调用信号处理程序。



核心代码:阻塞与解除阻塞信号
#include <signal.h>





sigset_t set;
sigemptyset(&set); // 初始化空信号集
sigaddset(&set, SIGCHLD); // 将 SIGCHLD 加入集合



// 在 fork 和关键操作前,阻塞 SIGCHLD
sigprocmask(SIG_BLOCK, &set, NULL);

// ... 执行 fork、将子进程加入列表等关键操作 ...
// 关键操作完成后,解除阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
通过这种方式,我们确保了“添加作业到列表”的操作一定发生在“从列表移除作业”的信号处理程序之前,从而消除了竞态条件。




其他相关系统调用

除了已经介绍的内容,还有两个重要的系统调用用于发送信号:

kill:向指定进程发送一个信号。kill(pid, SIGUSR1); // 向进程 pid 发送用户自定义信号 SIGUSR1raise:向进程自身发送一个信号。raise(SIGTERM); // 向自己发送终止信号

kill 命令功能强大,除了发送终止信号,还可以发送任何其他信号。通过指定负数的进程组ID,还可以向整个进程组发送信号。






总结



本节课中我们一起学习了操作系统信号机制的核心内容:
- 信号基础:信号是简单的进程间事件通知机制,通过编号标识。
- 信号处理程序:我们编写了处理
SIGCHLD信号的函数,用于收割结束的子进程,并学会了使用WNOHANG选项处理多个子进程同时结束的情况。 - 竞态条件:我们认识了信号处理中由于时序不确定性导致的竞态条件问题。
- 信号同步:我们使用
sigprocmask、信号集等工具阻塞和解除阻塞信号,确保了关键代码段的执行顺序,解决了竞态条件。 - 信号发送:我们了解了
kill和raise系统调用,用于主动发送信号。
信号是并发编程中强大但需要谨慎使用的工具。正确理解和使用信号,对于编写健壮的多进程程序至关重要。

课程8:竞争条件、死锁与数据完整性 🧩


在本节课中,我们将学习进程间通信中信号处理的高级概念,特别是如何识别和避免竞争条件与死锁。我们将通过分析一个简单的Shell程序示例,来理解信号阻塞、自旋等待以及sigsuspend系统调用的正确用法,以确保程序的逻辑正确性和数据完整性。


概述


信号是进程间通信的一种重要机制,但不当的信号处理会导致竞争条件和数据不一致。本节课我们将深入探讨以下内容:
- 回顾信号处理的基本机制。
- 分析由信号引发的典型竞争条件。
- 学习使用信号屏蔽(
sigprocmask)来避免竞争。 - 理解并避免低效的自旋等待。
- 掌握使用
sigsuspend系统调用来安全地等待信号。





信号处理回顾与一个误导的更正
上一节我们介绍了信号处理函数和SIGCHLD信号。这里需要先纠正一个之前的误导。
在之前的例子中,我们讨论了子进程执行execvp后,其原有的信号处理器会如何。正确的理解是:当子进程调用execvp时,原进程的整个程序映像(包括其安装的信号处理器)会被新程序取代。因此,原进程中的信号处理器将不复存在,新程序会使用默认的信号处理方式或自己安装新的处理器。


关键点:execvp会摧毁调用它的进程中原有的一切,原程序中的任何代码(包括信号处理器)都不会再被执行。
发送信号:kill与raise系统调用

除了操作系统内核产生信号,进程也可以主动发送信号。这主要通过两个系统调用实现:
kill: 向指定进程ID(PID)的进程发送一个信号。它的命名并不准确,并非总是“杀死”进程。kill(pid, SIGUSR1); // 向进程pid发送SIGUSR1信号raise: 向进程自身发送一个信号。raise(SIGSTOP); // 进程向自己发送SIGSTOP信号,使自己暂停
以下是一个简单的示例程序,演示raise的用法:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>


int main() {
printf("我将终止自己的进程。\n");
raise(SIGKILL); // 进程向自己发送SIGKILL信号
// 以下代码永远不会执行
printf("这行不会打印。\n");
return 0;
}






简单Shell中的问题:未回收的后台进程

现在,让我们将焦点转向一个实际的例子——一个简单的Shell。最初的版本可能如下伪代码所示:
void simple_shell() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行命令(例如通过execvp)
execvp(...);
} else {
// 父进程
if (command_is_background) {
printf("[%d] %s\n", pid, command);
// 问题:没有等待子进程,导致“僵尸进程”
} else {
waitpid(pid, ...); // 等待前台进程
}
}
}
问题:当命令在后台运行时,父进程(Shell)没有调用waitpid来回收结束的子进程。这会导致子进程结束后变成“僵尸进程”,占用系统资源,直到Shell本身终止。
引入信号处理器:回收子进程


为了解决后台进程的回收问题,我们为Shell安装一个SIGCHLD信号处理器。


void sigchld_handler(int sig) {
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
// 成功回收一个已终止的子进程
printf("进程 %d 已结束。\n", pid);
}
}

这个处理器会在任何子进程状态改变时被调用,并使用WNOHANG参数非阻塞地回收所有已终止的子进程。




竞争条件的出现


然而,仅仅添加信号处理器会引入竞争条件。考虑以下逻辑,我们想跟踪前台进程的PID(fg_pid):

- 父进程
fork出子进程。 - 如果它是前台进程,父进程将
fg_pid设置为子进程的PID。 - 父进程调用一个函数(如
wait_for_foreground)等待fg_pid被清零(清零操作在信号处理器中完成)。 - 子进程可能非常快地结束,并在父进程执行第2步(设置
fg_pid)之前,信号处理器就被调用。 - 信号处理器将
fg_pid清零。 - 父进程随后将
fg_pid设置为子进程PID,然后进入等待循环。 - 此时,
fg_pid不为零,但子进程已结束,信号处理器不会再被触发,导致父进程永远等待下去。




这就是一个典型的竞争条件:操作的结果依赖于进程调度(即步骤2和步骤4-5谁先发生)。


使用信号屏蔽消除竞争条件

为了避免上述竞争,我们需要阻塞(屏蔽) SIGCHLD信号,直到父进程准备好处理它。我们使用sigprocmask系统调用。
以下是修改后的安全流程:
- 在
fork之前,父进程阻塞SIGCHLD信号。sigset_t mask, oldmask; sigemptyset(&mask); sigaddset(&mask, SIGCHLD); sigprocmask(SIG_BLOCK, &mask, &oldmask); // 阻塞SIGCHLD - 执行
fork。 - 在子进程中,解除对
SIGCHLD的阻塞(因为子进程通常不需要关心这个信号)。 - 在父进程中:
- 如果是后台进程,直接返回,信号处理器稍后会处理。
- 如果是前台进程,先设置
fg_pid = child_pid。 - 然后解除对
SIGCHLD信号的阻塞。 - 最后,调用函数等待前台进程结束。

这样,确保了在fg_pid被正确设置之前,SIGCHLD信号处理器绝对不会被调用,从而消除了竞争。



低效的自旋等待及其避免


在等待前台进程结束的函数中,我们最初可能这样写:
void wait_for_foreground(pid_t pid) {
fg_pid = pid;
while (fg_pid == pid) {
// 空循环,什么也不做,直到信号处理器将fg_pid清零
}
}
这种循环称为自旋等待。它会持续占用CPU资源(使一个核心使用率达到100%),浪费电力且可能导致系统响应变慢。
我们希望进程在等待时能让出CPU。一个天真的改进是使用sleep或usleep,但这不够优雅且可能引入延迟。另一个想法是使用pause()函数,它会令进程休眠,直到收到任何信号。但这会引入新的死锁风险:


- 父进程设置
fg_pid。 - 在调用
pause()之前,子进程结束,信号处理器被调用并清空fg_pid。 - 父进程调用
pause(),此时已经没有未处理的SIGCHLD信号来唤醒它,导致父进程永远休眠。

安全的等待:sigsuspend系统调用
解决这个问题的正确方法是使用sigsuspend系统调用。它原子化地执行两个操作:1) 将进程的信号掩码替换为指定的集合;2) 使进程休眠,直到收到一个未被屏蔽的信号。
以下是使用sigsuspend的安全模式:
void wait_for_foreground(pid_t pid) {
fg_pid = pid;
sigset_t empty_mask;
sigemptyset(&empty_mask);
while (fg_pid == pid) {
// 原子化地:1) 解除所有信号阻塞(使用空掩码) 2) 休眠
sigsuspend(&empty_mask);
// 当任何信号(尤其是SIGCHLD)到达并处理完毕后,从这里恢复
}
// sigsuspend返回后,原始的信号掩码(阻塞SIGCHLD)会自动恢复
}
工作原理:
- 进入循环时,
SIGCHLD信号仍被阻塞。 - 调用
sigsuspend(&empty_mask)。这个调用会:- 临时将进程的信号掩码替换为空掩码(即不阻塞任何信号,包括
SIGCHLD)。 - 立即将进程置于睡眠状态。
- 临时将进程的信号掩码替换为空掩码(即不阻塞任何信号,包括
- 由于
SIGCHLD不再被阻塞,正在等待的子进程终止信号会立即送达,触发信号处理器。 - 信号处理器回收子进程,并将
fg_pid清零。 - 信号处理器返回后,
sigsuspend也随之返回,并自动恢复到调用前的信号掩码(即重新阻塞SIGCHLD)。 - 循环检查发现
fg_pid已不等于pid,循环结束。

sigsuspend的原子性(临时解除阻塞和进入睡眠是一个不可分割的操作)完美避免了pause()可能遇到的死锁情况。
期中考试样题分析

最后,我们通过一个往期期中考试题目来巩固对信号执行顺序的理解。

考虑以下程序:
void handler(int sig) { printf(“pirate“); exit(0); }
int main() {
signal(SIGUSR1, handler);
pid_t pid = fork();
if (pid == 0) {
printf(“ghost“);
return 0;
} else {
kill(pid, SIGUSR1);
printf(“ninja“);
return 0;
}
}
假设所有printf输出是原子的。请问输出序列“ghost ninja pirate”是否可能?


分析:
fork后,子进程可能先执行,打印“ghost”。- 父进程执行
kill发送SIGUSR1给子进程,然后立即打印“ninja”。 - 子进程在
return 0;之前收到信号,跳转到handler,打印“pirate”并调用exit(0)直接终止(不会执行return 0;)。 - 因此,输出“ghost ninja pirate”是可能的。

关键点:信号的传递和接收是异步的。子进程的主执行流(打印“ghost”)和信号处理流(打印“pirate”)是互斥的,但它们的相对顺序取决于调度。exit(0)会立即终止进程,阻止“ghost”在信号处理后被打印。
总结
本节课我们一起深入学习了信号处理中的高级议题:
- 竞争条件:当多个进程(或信号处理器)以不可预测的顺序访问和修改共享数据(如全局变量
fg_pid)时发生。 - 信号屏蔽:使用
sigprocmask阻塞特定信号,是保护关键代码段、消除竞争条件的核心手段。 - 自旋等待:应避免使用消耗CPU的空循环来等待事件。
- 安全等待信号:使用
sigsuspend系统调用可以原子化地解除信号阻塞并进入睡眠,是避免死锁、安全等待信号发生的标准方法。 - 逻辑推理:通过分析程序代码和信号异步特性,可以推理出可能的输出序列,这是理解和调试并发程序的重要技能。

掌握这些概念,对于编写正确、高效且稳健的并发程序至关重要。


课程 P9:作业 3 详解 - 多进程编程实战 🚀

在本节课中,我们将学习第三次作业的核心内容。本次作业聚焦于多进程编程,你将综合运用 fork、execvp、waitpid、管道以及 dup/dup2 等系统调用,完成四个不同程序。课程将分为四个主要部分进行讲解。


第一部分:管道(pipe)🛠️

上一节我们介绍了作业的整体结构,本节中我们来看看第一个任务——管道函数。

管道函数接收两个参数列表,每个列表代表一个要执行的命令及其参数。它还会填充一个初始为空的 PID 数组,最终包含两个子进程的 PID。其核心功能是:第一个进程的标准输出将成为第二个进程的标准输入。

管道的工作原理

管道通过 int fds[2] 创建两个文件描述符:
fds[0]是读取端。fds[1]是写入端。



写入 fds[1] 的数据可以从 fds[0] 读取。




一个具体例子



假设我们有一个文件 testfile.txt,内容是一些单词。我们想用 cat 命令显示其内容,并通过管道传递给 sort 命令进行排序。



在 Shell 中,命令如下:
cat testfile.txt | sort
在这个例子中:
cat从文件读取并输出到标准输出。sort从标准输入读取数据。- 管道
|将cat的标准输出重定向为sort的标准输入。



在你的 pipe 函数实现中,需要模拟这个过程。以下是关键步骤:
- 使用
pipe系统调用创建管道。 - 使用
fork创建第一个子进程(执行cat)。 - 在该子进程中,使用
dup2将管道的写入端 (fds[1]) 复制到标准输出文件描述符 (STDOUT_FILENO)。 - 使用
execvp执行cat命令。 - 使用
fork创建第二个子进程(执行sort)。 - 在该子进程中,使用
dup2将管道的读取端 (fds[0]) 复制到标准输入文件描述符 (STDIN_FILENO)。 - 使用
execvp执行sort命令。 - 在父进程中,使用
waitpid等待两个子进程结束。




第二部分:子进程(subprocess)👶


理解了管道的基本构造后,我们进入下一个任务——实现 subprocess 函数。




subprocess 函数用于创建一个可执行的子进程,并允许父进程控制其输入输出。它接收一个命令数组和两个布尔参数:
supplyChildInput:如果为真,父进程将获得一个可写入的文件描述符,写入的内容会成为子进程的标准输入。ingestChildOutput:如果为真,父进程将获得一个可读取的文件描述符,读取的内容是子进程的标准输出。



函数返回一个 subprocess_t 结构体,其中包含子进程的 PID 以及上述可选的输入/输出文件描述符。




这个函数在思路上与管道类似,但更通用。你需要处理以下情况:
- 当
supplyChildInput为真时,需要创建一个管道,并将管道的读取端作为子进程的标准输入。 - 当
ingestChildOutput为真时,需要创建另一个管道,并将管道的写入端作为子进程的标准输出。 - 在父进程中,根据布尔值决定是否关闭不需要的管道端,并保存需要使用的文件描述符到
subprocess_t结构体中。




第三部分:追踪(trace)🔍




完成了相对基础的进程控制后,我们来看一个更具挑战性的任务——实现一个简单的调试追踪器。




trace 函数的功能是启动一个程序,并捕获、报告该程序运行过程中发起的所有系统调用及其返回值。这就像编写一个小型调试器。



简单追踪模式


在简单模式下,输出格式类似:
syscall(59) -> 0
syscall(12) -> 1
syscall(4) -> 3
...
syscall(231) -> ?
其中,括号内的数字是系统调用号,箭头后是返回值(? 表示无返回值)。


完整追踪模式


在完整模式下,你需要利用提供的映射表,将系统调用号、错误码等转换为可读的名称。输出会包含更多细节,例如系统调用的具体名称。



实现关键:ptrace 系统调用




实现追踪功能的核心是 ptrace 系统调用。它允许一个进程(父进程/追踪者)观察和控制另一个进程(子进程/被追踪者)的执行。




基础代码框架已经提供,主要流程如下:
- 父进程
fork出子进程。 - 在子进程中,调用
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr)请求被追踪,然后调用raise(SIGSTOP)暂停自己。 - 在父进程中,调用
waitpid等待子进程进入暂停状态。 - 父进程进入循环,反复调用
ptrace(PTRACE_SYSCALL, child_pid, nullptr, nullptr)让子进程执行,直到遇到下一个系统调用入口或出口时停止。 - 父进程通过
ptrace(PTRACE_PEEKUSER, child_pid, ...)读取子进程的寄存器(如RAX),以获取系统调用号或返回值。 - 父进程打印信息,然后继续循环,直到子进程退出。



你的任务是在此框架基础上,修改代码以实现简单模式和完整模式的输出。你需要仔细阅读提供的映射表头文件,并理解如何读取系统调用的参数。



第四部分:农场(farm)🌾


最后,我们来看一个综合应用——farm 程序。它将利用多核处理器并行进行大数分解。


整体架构
farm 程序会作为协调者(父进程):
- 生成工人:为每个 CPU 核心
fork出一个工人子进程。每个工人子进程执行一个给定的 Python 分解脚本。 - 分配任务:从标准输入读取一系列待分解的数字,然后将这些数字分发给各个空闲的工人进程。
- 收集结果:工人进程完成分解后输出结果和耗时。
- 关闭农场:所有数字处理完毕后,关闭所有工人进程。



工人进程与 Python 脚本



工人进程运行一个 Python 脚本,其工作流程如下:
- 启动后,立即暂停自己,等待协调者发送数字。
- 收到协调者发送的数字后,开始计时并进行分解。
- 分解完成后,停止计时,输出结果。
- 返回步骤 1,等待下一个数字,直到输入通道被关闭(读到 EOF)。



你的实现任务

大部分框架代码已提供。你需要实现的核心函数包括:
spawnAllWorkers:生成所有工人子进程。broadcastNumberToWorkers:将数字分发给空闲的工人。waitAllWorkers:等待所有工人完成当前任务。closeAllWorkers:关闭所有工人进程(通过关闭向其写入的文件描述符来发送 EOF)。


关键点在于管理工人的状态(忙/闲),并使用信号(如 SIGCHLD)或 waitpid 的非阻塞模式来高效地处理多个子进程。








本节课总结 🎯


在本节课中,我们一起学习了第三次作业的四个核心部分:
- 管道:实现了连接两个进程输入输出的
pipe函数。 - 子进程:实现了可灵活控制子进程输入输出的
subprocess函数。 - 追踪:使用
ptrace系统调用实现了一个能拦截并报告系统调用的简单调试器。 - 农场:综合运用多进程技术,实现了一个并行计算框架,用于分解大数。



通过本次作业,你将深入理解 Linux 多进程编程、进程间通信以及进程调试的核心机制。如果在实现过程中遇到问题,请及时在课程讨论区或答疑时间寻求帮助。


浙公网安备 33010602011771号