diff --git a/.claude/agents/content-reviewer.md b/.claude/agents/content-reviewer.md new file mode 100644 index 0000000..411d314 --- /dev/null +++ b/.claude/agents/content-reviewer.md @@ -0,0 +1,142 @@ +--- +name: content-reviewer +description: 专门用于审查 TCP-IP-NetworkNote 项目中各章节 README.md 文档的内容正确性、校准技术描述及校验习题答案的 Agent。 +model: opus +--- + +你是一位精通 TCP/IP 网络编程、Linux 系统编程(C 语言)以及 Windows Socket 编程的技术专家。你的任务是审查 `TCP-IP-NetworkNote` 项目中各章节的 `README.md` 文档。 + +## 你的核心职责 + +### 1. 技术内容校准与正确性审核 + +- **技术准确性**:检查文档中关于以下技术的描述是否准确: + - TCP/IP 协议栈(链路层、IP 层、TCP/UDP 层、应用层) + - Socket API(`socket`, `bind`, `listen`, `accept`, `connect`, `send`, `recv`, `shutdown` 等) + - 文件描述符与 I/O 操作(`read`, `write`, `open`, `close`) + - 进程相关(`fork`, `exec`, `wait`, `waitpid`) + - 线程相关(`pthread_create`, `pthread_join`, `pthread_mutex_*`) + - I/O 复用(`select`, `epoll_create`, `epoll_ctl`, `epoll_wait`) + - 信号处理(`signal`, `sigaction`) + - 套接字选项(`getsockopt`, `setsockopt`) + - 多播与广播 + +- **代码一致性**:文档中引用的代码文件名(如 `hello_server.c`)是否与实际文件一致,路径是否正确。 + +- **术语规范**: + - 确保 Linux 和 Windows API 的区分(如 `close` vs `closesocket`) + - "文件描述符"(Linux)vs "句柄"(Windows) + - "接收"(receive)不要写成"接受" + - "连接"(connection)不要写成"链接"(link) + - IPv4/IPv6 大小写规范(不要用 IPV4/IPV6) + +- **错别字与格式**: + - 修正所有错别字(如"候"→"后"、"同事"→"同时") + - 修正 Markdown 格式问题 + - 确保代码块有正确的语言标记(`c`、`shell` 等) + +### 2. 习题正确性校验(核心任务) + +每章文档末尾通常包含"习题"章节。你需要: + +- **验证现有答案**:检查已有的答案是否正确。 +- **补充缺失答案**:如果答案标注为"暂略"、"略"或空白,请根据章节内容补充正确答案。 +- **优化模糊答案**:如果答案表述不清或存在歧义,请进行优化。 +- **统一答案格式**:确保答案清晰、专业,避免过度口语化。 + +### 3. 语言规范 + +- 确保所有内容为流畅、专业的简体中文。 +- 保持原本的文档结构,不要随意删除未涉及错误的段落。 +- 对于"基于 Windows 的实现"章节,如原文标注"暂略",保持原样即可。 + +## 项目特定信息 + +### 章节列表(共 19 章) + +| 章节 | 主题 | +|------|------| +| ch01 | 理解网络编程和套接字 | +| ch02 | 套接字类型与协议设置 | +| ch03 | 地址族与数据序列 | +| ch04 | 基于 TCP 的服务端/客户端(1) | +| ch05 | 基于 TCP 的服务端/客户端(2) | +| ch06 | 基于 UDP 的服务端/客户端 | +| ch07 | 优雅地断开套接字的连接 | +| ch08 | 域名及网络地址 | +| ch09 | 套接字的各种选项 | +| ch10 | 多进程服务器端 | +| ch11 | 进程间通信 | +| ch12 | I/O 复用 | +| ch13 | I/O 复用(2) | +| ch14 | 多播与广播 | +| ch15 | 套接字和标准 I/O | +| ch16 | 关于 I/O 流分离的其他内容 | +| ch17 | 优于 select 的 epoll | +| ch18 | 多线程服务器端的实现 | +| ch24 | 制作 HTTP 服务器端 | + +### 常见术语对照表 + +| 正确写法 | 错误写法 | +|---------|---------| +| IPv4 / IPv6 | IPV4 / IPV6 / Ipv4 | +| 接收数据 | 接受数据 | +| 连接请求 | 链接请求 | +| 半关闭 | 半关闭 | +| 文件描述符 | 文件句柄(Linux 上下文中) | +| 套接字 | socket(混用中英文) | + +### 代码文件命名规范 + +- 服务端代码:`*_server.c` 或 `*_serv.c` +- 客户端代码:`*_client.c` 或 `*_clnt.c` +- 示例代码:使用描述性名称,如 `hello_server.c`, `echo_client.c` + +## 工作流程 + +1. **读取文档**:使用 `Read` 工具读取目标章节的 `README.md` 文件。 + +2. **全文审查**: + - 检查技术概念解释是否准确 + - 检查代码示例说明是否正确 + - 修正拼写和格式错误 + - 核对代码文件引用是否与实际文件一致 + +3. **习题深度审计**: + - 逐题检查答案正确性 + - 补充标注"暂略"的答案 + - 优化模糊或口语化的答案 + +4. **应用修正**: + - 使用 `Edit` 工具对文档进行精准修改 + - 对于多处修改,可使用 `Edit` 工具多次调用 + +5. **输出摘要**:提供审查摘要,列出发现的主要问题和修正内容。 + +## 审查示例 + +**问题 1 - 错别字**: +- 原文:"调用函数候还剩下 1 个文件描述符" +- 修正:"调用函数**后**还剩下 1 个文件描述符" + +**问题 2 - 术语错误**: +- 原文:"IPV4 地址族" +- 修正:"**IPv4** 地址族" + +**问题 3 - 习题答案缺失**: +- 原文:"> 答:暂略。" +- 修正:根据章节内容补充完整的技术说明。 + +**问题 4 - 技术描述不准确**: +- 原文:"Linux 和 Windows 的 closesocket 函数" +- 修正:"**Linux 的 close 函数和 Windows 的 closesocket 函数**" + +## 重要提醒 + +- 本仓库专注于 **Linux 平台**的套接字编程,Windows 相关内容大多标记为"暂略"。 +- 保持原文档的**章节结构**和**代码引用格式**。 +- 对于技术上有争议的内容,以主流 Linux 系统编程实践为准。 +- 修正后的文档应更加**准确、清晰、专业**。 + +你做事严谨、技术功底深厚,致力于将这份学习笔记打造得完美无误,帮助读者更好地理解 TCP/IP 网络编程。 diff --git a/.claude/agents/merger.md b/.claude/agents/merger.md new file mode 100644 index 0000000..68a478a --- /dev/null +++ b/.claude/agents/merger.md @@ -0,0 +1,111 @@ +--- +name: merger +description: 专门用于将 TCP-IP-NetworkNote 项目中各子目录的 README.md 文档整合到根目录 README.md 的 Agent。 +model: opus +--- + +你是一个负责文档整合的 Agent。你的任务是将 `TCP-IP-NetworkNote` 项目中各子目录的 `README.md` 内容整合到根目录的 `README.md` 文件中。 + +## 你的核心职责 + +### 将各章节 README.md 合并到根 README.md + +根目录的 `README.md` 文件结构如下: +``` +开头 → "## 第一章:理解网络编程和套接字" 之前的内容:项目说明(需要保留) +"## 第一章:理解网络编程和套接字" 之后的内容:各章节内容(需要用各子目录的最新内容替换) +``` + +### 工作流程 + +1. **定位章节起始行**:在根 README.md 中搜索 `## 第一章:理解网络编程和套接字`,获取其行号。 + +2. **保留项目说明**:提取从第 1 行到章节起始行**之前**的所有内容作为项目说明(保留)。 + +3. **按章节顺序拼接**:按以下顺序读取各子目录的 README.md 并追加: + ``` + ch01/README.md + ch02/README.md + ch03/README.md + ch04/README.md + ch05/README.md + ch06/README.md + ch07/README.md + ch08/README.md + ch09/README.md + ch10/README.md + ch11/README.md + ch12/README.md + ch13/README.md + ch14/README.md + ch15/README.md + ch16/README.md + ch17/README.md + ch18/README.md + ch24/README.md + ``` + +4. **写入根 README.md**:将保留的项目说明 + 所有章节内容写入根 README.md。 + +### 实现方法 + +#### 方法一:使用 grep 定位 + 合并 + +```bash +# 搜索章节起始行,获取行号 +CHAPTER_LINE=$(grep -n "^## 第一章" README.md | head -1 | cut -d: -f1) + +# 提取项目说明(章节起始行之前的内容) +head -$((CHAPTER_LINE - 1)) README.md > /tmp/merged_readme.md + +# 追加所有章节的 README.md +cat ch01/README.md ch02/README.md ch03/README.md ch04/README.md \ + ch05/README.md ch06/README.md ch07/README.md ch08/README.md \ + ch09/README.md ch10/README.md ch11/README.md ch12/README.md \ + ch13/README.md ch14/README.md ch15/README.md ch16/README.md \ + ch17/README.md ch18/README.md ch24/README.md >> /tmp/merged_readme.md + +# 覆盖根 README.md +cp /tmp/merged_readme.md README.md +``` + +#### 方法二:使用 awk 一次性完成 + +```bash +awk ' + BEGIN { found = 0 } + /^## 第一章/ { found = 1; exit } + { print } +' README.md > /tmp/merged_readme.md + +cat ch01/README.md ch02/README.md ch03/README.md ch04/README.md \ + ch05/README.md ch06/README.md ch07/README.md ch08/README.md \ + ch09/README.md ch10/README.md ch11/README.md ch12/README.md \ + ch13/README.md ch14/README.md ch15/README.md ch16/README.md \ + ch17/README.md ch18/README.md ch24/README.md >> /tmp/merged_readme.md + +cp /tmp/merged_readme.md README.md +``` + +### 验证 + +合并完成后,验证: +1. 根 README.md 的行数(正常约 6000+ 行) +2. 开头部分是项目说明 +3. "## 第一章"之后是各章节内容 +4. 文件末尾内容完整 + +## 注意事项 + +- 不要修改各子目录的 README.md 内容 +- 保持各章节的原始顺序 +- 合并后检查文件末尾是否完整 +- 如遇到某个章节文件不存在,跳过该文件并记录警告 +- 项目说明可能包含空行,保留原样 + +## 项目信息 + +- **项目路径**:`/Users/hepengcheng/airepo/TCP-IP-NetworkNote` +- **章节数量**:19 个(ch01-ch18,ch24) +- **根 README.md**:约 6000+ 行 +- **章节起始标记**:`^## 第一章` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..116ecc5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +本文件为 Claude Code (claude.ai/code) 在此仓库中工作时提供指导。 + +## 概述 + +这是《TCP/IP 网络编程》一书的学习笔记仓库,包含按章节组织的 Markdown 笔记和配套的 C 语言示例代码,演示套接字编程的核心概念。 + +**开发环境**:Ubuntu 18.04 LTS,gcc 7.3.0 + +**仓库地址**:https://github.com/riba2534/TCP-IP-NetworkNote + +## 目录结构 + +``` +ch01/ - 第 1 章:理解网络编程和套接字 +ch02/ - 第 2 章:套接字类型与协议设置 +ch03/ - 第 3 章:地址族与数据序列 +ch04/ - 第 4 章:基于 TCP 的服务端/客户端(1) +ch05/ - 第 5 章:基于 TCP 的服务端/客户端(2) +ch06/ - 第 6 章:基于 UDP 的服务端/客户端 +ch07/ - 第 7 章:优雅地断开套接字的连接 +ch08/ - 第 8 章:域名及网络地址 +ch09/ - 第 9 章:套接字的各种选项 +ch10/ - 第 10 章:多进程服务器端 +ch11/ - 第 11 章:进程间通信 +ch12/ - 第 12 章:I/O 复用 +ch13/ - 第 13 章:I/O 复用(2) +ch14/ - 第 14 章:多播与广播 +ch15/ - 第 15 章:套接字和标准 I/O +ch16/ - 第 16 章:关于 I/O 流分离的其他内容 +ch17/ - 第 17 章:优于 select 的 epoll +ch18/ - 第 18 章:多线程服务器端的实现 +ch24/ - 第 24 章:制作 HTTP 服务器端 +``` + +每个章节目录包含: +- `README.md` - 章节笔记(理论 + 习题) +- `.c` 文件 - 示例代码 + +## 编译和运行 + +所有示例都是独立的 C 程序,使用 gcc 编译: + +```bash +# 编译服务端示例 +gcc hello_server.c -o hserver + +# 编译客户端示例 +gcc hello_client.c -o hclient + +# 运行服务端(需要端口号参数) +./hserver 9190 + +# 运行客户端(需要 IP 地址和端口号) +./hclient 127.0.0.1 9190 +``` + +多线程示例(第 18 章)需要链接 pthread 库: +```bash +gcc thread1.c -o tr1 -lpthread +``` + +## 内容规范 + +修改此仓库内容时需注意: + +1. **专注于 Linux 平台**:本仓库专门讲解 Linux 套接字编程。Windows 平台相关内容标记为"暂略"。 + +2. **保持中文术语一致性**: + - IPv4/IPv6(不要用 IPV4/IPV6) + - "接收"(receive)不要写成"接受" + - "连接"(connection)不要写成"链接" + +3. **章节更新流程**:根目录 README.md 包含所有 19 个章节的合并内容。更新内容时,先修改子目录中对应章节的 README.md,然后合并到根 README.md。 + +4. **代码引用格式**:引用示例代码时使用相对路径,不加章节前缀(例如用 `[hello_server.c](hello_server.c)` 而不是 `[hello_server.c](ch01/hello_server.c)`)。 + +## 涵盖的核心套接字编程概念 + +- **TCP vs UDP**:面向连接(SOCK_STREAM)vs 无连接(SOCK_DGRAM) +- **服务端生命周期**:socket() → bind() → listen() → accept() → read/write → close() +- **客户端生命周期**:socket() → connect() → read/write → close() +- **I/O 模型**:阻塞、非阻塞、I/O 复用(select、epoll) +- **并发方式**:多进程(fork)、多线程(pthread) +- **高级主题**:半关闭(shutdown)、套接字选项、多播/广播 diff --git a/README.md b/README.md index 4f43850..e5d502f 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ 笔记的 PDF 版本可以在本项目 [releases](https://github.com/riba2534/TCP-IP-NetworkNote/releases) 中找到及下载。 -## 第 1 章:理解网络编程和套接字 +## 第一章:理解网络编程和套接字 -本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到,直接点连接可能进不去。 +本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到,直接点链接可能进不去。 ### 1.1 理解网络编程和套接字 @@ -44,7 +44,7 @@ int socket(int domain, int type, int protocol); > > 答:我的电话号码是123-1234 -套接字同样如此。就想给电话机分配电话号码一样,利用以下函数给创建好的套接字分配地址信息(IP地址和端口号): +套接字同样如此。就像给电话机分配电话号码一样,利用以下函数给创建好的套接字分配地址信息(IP地址和端口号): ```c #include @@ -95,7 +95,7 @@ int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 服务器端(server)是能够受理连接请求的程序。下面构建服务端以验证之前提到的函数调用过程,该服务器端收到连接请求后向请求者返回`Hello World!`答复。除各种函数的调用顺序外,我们还未涉及任何实际编程。因此,阅读代码时请重点关注套接字相关的函数调用过程,不必理解全过程。 -服务器端代码请参见:[hello_server.c](ch01/hello_server.c) +服务器端代码请参见:[hello_server.c](hello_server.c) **客户端**: @@ -104,7 +104,7 @@ int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 1. 调用 socket 函数 和 connect 函数 2. 与服务端共同运行以收发字符串数据 -客户端代码请参见:[hello_client.c](ch01/hello_client.c) +客户端代码请参见:[hello_client.c](hello_client.c) **编译**: @@ -122,7 +122,7 @@ gcc hello_client.c -o hclient ./hclient 127.0.0.1 9190 ``` -运行的时候,首先再 9190 端口启动服务,然后 heserver 就会一直等待客户端进行响应,当客户端监听位于本地的 IP 为 127.0.0.1 的地址的9190端口时,客户端就会收到服务端的回应,输出`Hello World!` +运行的时候,首先在 9190 端口启动服务,然后 hserver 就会一直等待客户端进行连接,当客户端连接位于本地的 IP 为 127.0.0.1 的地址的 9190 端口时,客户端就会收到服务端的回应,输出`Hello World!` ### 1.2 基于 Linux 的文件操作 @@ -140,7 +140,7 @@ gcc hello_client.c -o hclient 文件和套接字一般经过创建过程才会被分配文件描述符。 -文件描述符也被称为「文件句柄」,但是「句柄」主要是 Windows 中的术语。因此,在本书中如果设计 Windows 平台将使用「句柄」,如果是 Linux 将使用「描述符」。 +文件描述符也被称为「文件句柄」,但是「句柄」主要是 Windows 中的术语。因此,在本书中如果涉及 Windows 平台将使用「句柄」,如果是 Linux 将使用「描述符」。 #### 1.2.2 打开文件: @@ -178,7 +178,7 @@ fd : 需要关闭的文件或套接字的文件描述符 */ ``` -若调用此函数同时传递文件描述符参数,则关闭(终止)响应文件。另外需要注意的是,此函数不仅可以关闭文件,还可以关闭套接字。再次证明了「Linux 操作系统不区分文件与套接字」的特点。 +若调用此函数同时传递文件描述符参数,则关闭(终止)相应文件。另外需要注意的是,此函数不仅可以关闭文件,还可以关闭套接字。再次证明了「Linux 操作系统不区分文件与套接字」的特点。 #### 1.2.4 将数据写入文件: @@ -193,11 +193,11 @@ nbytes : 要传输数据的字节数 */ ``` -在此函数的定义中,size_t 是通过 typedef 声明的 unsigned int 类型。对 ssize_t 来说,ssize_t 前面多加的 s 代表 signed ,即 ssize_t 是通过 typedef 声明的 signed int 类型。 +在此函数的定义中,size_t 是通过 typedef 声明的无符号整型(通常为 `unsigned long`)。对 ssize_t 来说,ssize_t 前面多加的 s 代表 signed(有符号),即 ssize_t 是通过 typedef 声明的有符号整型(通常为 `signed long`)。 创建新文件并保存数据: -代码见:[low_open.c](ch01/low_open.c) +代码见:[low_open.c](low_open.c) 编译运行: @@ -225,7 +225,7 @@ nbytes : 要接收数据的最大字节数 下面示例通过 read() 函数读取 data.txt 中保存的数据。 -代码见:[low_read.c](ch01/low_read.c) +代码见:[low_read.c](low_read.c) 编译运行: @@ -234,7 +234,7 @@ gcc low_read.c -o lread ./lread ``` -在上一步的 data.txt 文件与没有删的情况下,会输出: +在上一步的 data.txt 文件如果没有删的情况下,会输出: ``` file descriptor: 3 @@ -247,7 +247,7 @@ file data: Let's go! 下面将同时创建文件和套接字,并用整数型态比较返回的文件描述符的值. -代码见:[fd_seri.c](ch01/fd_seri.c) +代码见:[fd_seri.c](fd_seri.c) **编译运行**: @@ -286,7 +286,7 @@ file descriptor 3: 16 3. Linux 中,对套接字数据进行 I/O 时可以直接使用文件 I/O 相关函数;而在 Windows 中则不可以。原因为何? - > 答:Linux把套接字也看作是文件,所以可以用文件I/O相关函数;而Windows要区分套接字和文件,所以设置了特殊的函数。 + > 答:在 Linux 中,套接字(socket)被视为文件的一种,遵循「一切皆文件」的设计哲学。因此可以使用标准的文件 I/O 函数(如 `read`、`write`、`close`)对套接字进行操作。而在 Windows 中,套接字和文件是区分开的,套接字操作需要使用专门的 Winsock 函数(如 `send`、`recv`、`closesocket`),不能使用文件 I/O 函数(如 `ReadFile`、`WriteFile`)直接操作套接字。 4. 创建套接字后一般会给他分配地址,为什么?为了完成地址分配需要调用哪个函数? @@ -294,7 +294,7 @@ file descriptor 3: 16 5. Linux 中的文件描述符与 Windows 的句柄实际上非常类似。请以套接字为对象说明它们的含义。 - > 答:暂略。 + > 答:文件描述符(File Descriptor)是 Linux 内核为了高效管理已被打开的文件所创建的索引,用于标识打开的文件、套接字等 I/O 资源,是一个非负整数。句柄(Handle)是 Windows 中用于标识资源(如文件、套接字、窗口等)的抽象引用,本质上是一个指针或索引。两者的相似之处在于:它们都是操作系统用来标识和访问资源的标识符;区别在于:Linux 的文件描述符是整数值,且遵循「一切皆文件」原则,套接字可以使用文件 I/O 函数操作;而 Windows 的句柄是 opaque 类型(不透明的指针类型),且需要针对不同资源类型使用专门的 API 函数。 6. 底层 I/O 函数与 ANSI 标准定义的文件 I/O 函数有何区别? @@ -302,9 +302,105 @@ file descriptor 3: 16 7. 参考本书给出的示例`low_open.c`和`low_read.c`,分别利用底层文件 I/O 和 ANSI 标准 I/O 编写文件复制程序。可任意指定复制程序的使用方法。 - > 答:暂略。 - -## 第 2 章 套接字类型与协议设置 + > 答: + > + > **使用底层文件 I/O(Linux 系统调用)的文件复制程序:** + > + > ```c + > #include + > #include + > #include + > #include + > #include + > + > #define BUF_SIZE 4096 + > + > int main(int argc, char *argv[]) { + > if (argc != 3) { + > printf("Usage: %s \n", argv[0]); + > exit(1); + > } + > + > int src_fd = open(argv[1], O_RDONLY); + > if (src_fd == -1) { + > perror("open source file failed"); + > exit(1); + > } + > + > int dest_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); + > if (dest_fd == -1) { + > perror("open dest file failed"); + > close(src_fd); + > exit(1); + > } + > + > char buf[BUF_SIZE]; + > ssize_t read_cnt; + > + > while ((read_cnt = read(src_fd, buf, BUF_SIZE)) > 0) { + > write(dest_fd, buf, read_cnt); + > } + > + > close(src_fd); + > close(dest_fd); + > + > return 0; + > } + > ``` + > + > **使用 ANSI 标准 I/O(标准 C 库)的文件复制程序:** + > + > ```c + > #include + > #include + > + > #define BUF_SIZE 4096 + > + > int main(int argc, char *argv[]) { + > if (argc != 3) { + > printf("Usage: %s \n", argv[0]); + > exit(1); + > } + > + > FILE *src_fp = fopen(argv[1], "rb"); + > if (src_fp == NULL) { + > perror("fopen source file failed"); + > exit(1); + > } + > + > FILE *dest_fp = fopen(argv[2], "wb"); + > if (dest_fp == NULL) { + > perror("fopen dest file failed"); + > fclose(src_fp); + > exit(1); + > } + > + > char buf[BUF_SIZE]; + > size_t read_cnt; + > + > while ((read_cnt = fread(buf, 1, BUF_SIZE, src_fp)) > 0) { + > fwrite(buf, 1, read_cnt, dest_fp); + > } + > + > fclose(src_fp); + > fclose(dest_fp); + > + > return 0; + > } + > ``` + > + > **编译运行示例:** + > + > ```shell + > # 底层 I/O 版本 + > gcc file_copy_low.c -o fcopy_low + > ./fcopy_low source.txt dest.txt + > + > # 标准 I/O 版本 + > gcc file_copy_stdio.c -o fcopy_stdio + > ./fcopy_stdio source.txt dest.txt + > ``` +## 第二章 套接字类型与协议设置 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到,直接点连接可能进不去。 @@ -334,13 +430,13 @@ protocol: 计算机间通信中使用的协议信息 | 名称 | 协议族 | | --------- | -------------------- | -| PF_INET | IPV4 互联网协议族 | -| PF_INET6 | IPV6 互联网协议族 | +| PF_INET | IPv4 互联网协议族 | +| PF_INET6 | IPv6 互联网协议族 | | PF_LOCAL | 本地通信 Unix 协议族 | | PF_PACKET | 底层套接字的协议族 | -| PF_IPX | IPX Novel 协议族 | +| PF_IPX | IPX Novell 协议族 | -本书着重讲 PF_INET 对应的 IPV4 互联网协议族。其他协议并不常用,或并未普及。**另外,套接字中采用的最终的协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。** +本书着重讲 PF_INET 对应的 IPv4 互联网协议族。其他协议并不常用,或并未普及。**另外,套接字中采用的最终的协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。** #### 2.1.3 套接字类型(Type) @@ -379,7 +475,7 @@ protocol: 计算机间通信中使用的协议信息 - 传输的数据有边界 - 限制每次传输数据的大小 -面向消息的套接字比面向连接的套接字更具哟传输速度,但可能丢失。特点可总结为: +面向消息的套接字比面向连接的套接字更具有传输速度,但可能丢失。特点可总结为: **不可靠的、不按序传递的、以数据的高速传输为目的套接字。** @@ -389,13 +485,13 @@ socket 函数的第三个参数决定最终采用的协议。前面已经通过 > 可以应对同一协议族中存在的多个数据传输方式相同的协议,所以数据传输方式相同,但是协议不同,需要用第三个参数指定具体的协议信息。 -本书用的是 Ipv4 的协议族,和面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP ,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。 +本书用的是 IPv4 的协议族,和面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP ,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。 ```c int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); ``` -SOCK_DGRAM 指的是面向消息的数据传输方式,满足上述条件的协议只有 TPPROTO_UDP 。这种套接字称为 UDP 套接字: +SOCK_DGRAM 指的是面向消息的数据传输方式,满足上述条件的协议只有 IPPROTO_UDP 。这种套接字称为 UDP 套接字: ```c int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP); @@ -433,7 +529,99 @@ Function read call count: 13 ### 2.2 Windows 平台下的实现及验证 -暂略 +在 Windows 平台下使用套接字需要进行一些初始化工作,这与 Linux 平台有所不同。 + +#### 2.2.1 Winsock 初始化 + +Windows 下的套接字称为 Winsock,使用前必须调用 `WSAStartup` 函数进行初始化: + +```c +#include +int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData); +/* +成功时返回0,失败时返回非0错误代码 +wVersionRequested: 程序员请求的Winsock版本,高字节指副版本,低字节指主版本 +lpWSAData: 指向WSADATA结构的指针,用于接收Windows Sockets实现的详细信息 +*/ +``` + +使用 Winsock 完成后,应调用 `WSACleanup` 函数释放资源: + +```c +int WSACleanup(void); +/* +成功时返回0,失败时返回SOCKET_ERROR +*/ +``` + +#### 2.2.2 Windows 与 Linux 套接字编程的主要区别 + +| 特性 | Linux | Windows | +| ---- | ----- | ------- | +| 头文件 | `sys/socket.h` 等 | `winsock2.h`, `ws2tcpip.h` | +| 初始化 | 无需初始化 | 必须调用 `WSAStartup` | +| 套接字类型 | 文件描述符(int) | `SOCKET` 类型(实际是 `UINT_PTR`) | +| 错误检查 | 返回-1表示失败 | 返回 `INVALID_SOCKET` 表示失败 | +| 关闭套接字 | `close(fd)` | `closesocket(socket)` | +| 获取错误码 | 访问全局变量 `errno` | 调用 `WSAGetLastError()` | +| I/O 函数 | `read`, `write` 可用于套接字 | `recv`, `send` 必须用于套接字 | + +#### 2.2.3 Windows 平台代码示例 + +Windows 平台下的 TCP 客户端和服务端代码结构类似,主要区别在于初始化和清理过程。以下是 Windows 版本的基本结构: + +```c +#include +#include + +#pragma comment(lib, "ws2_32.lib") // 链接 Winsock 库 + +int main(int argc, char *argv[]) +{ + WSADATA wsaData; + SOCKET hServSock, hClntSock; + SOCKADDR_IN servAddr, clntAddr; + int szClntAddr; + + // 1. 初始化 Winsock + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + printf("WSAStartup() error"); + return 1; + } + + // 2. 创建套接字 + hServSock = socket(PF_INET, SOCK_STREAM, 0); + if (hServSock == INVALID_SOCKET) { + printf("socket() error"); + WSACleanup(); + return 1; + } + + // ... 其余代码与 Linux 版本类似 ... + + // 9. 关闭套接字 + closesocket(hServSock); + + // 10. 清理 Winsock + WSACleanup(); + + return 0; +} +``` + +编译命令(使用 Visual Studio 的命令行工具): + +```cmd +cl tcp_client_win.c /link ws2_32.lib +cl tcp_server_win.c /link ws2_32.lib +``` + +或者使用 MinGW: + +```cmd +gcc tcp_client_win.c -o hclient -lws2_32 +gcc tcp_server_win.c -o hserver -lws2_32 +``` ### 2.3 习题 @@ -461,8 +649,10 @@ Function read call count: 13 5. 何种类型的套接字不存在数据边界?这类套接字接收数据时应该注意什么? - > 答:TCP 不存在数据边界。在接收数据时,需要保证在接收套接字的缓冲区填充满之时就从buffer里读取数据。也就是,在接收套接字内部,写入buffer的速度要小于读出buffer的速度。 - + > 答:面向连接的 TCP 套接字不存在数据边界。接收数据时需要注意: + > 1. **数据可能分多次到达**:发送方调用一次 write 发送 100 字节,接收方可能需要多次 read 才能读完,或者一次 read 就能读完多次 write 的数据。 + > 2. **需要定义应用层协议**:由于没有边界,必须在应用层定义数据边界(如固定长度、分隔符、长度前缀等方式),否则无法正确解析数据。 + > 3. **缓冲区管理**:虽然 TCP 内部有流量控制机制保证不丢数据,但应用层仍应及时读取数据,避免接收缓冲区占用过多内存。 ## 第 3 章 地址族与数据序列 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -471,18 +661,18 @@ Function read call count: 13 ### 3.1 分配给套接字的 IP 地址与端口号 -IP 是 Internet Protocol(网络协议)的简写,是为手法网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为了区分程序中创建的套接字而分配给套接字的端口号。 +IP 是 Internet Protocol(网络协议)的简写,是为收发网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为了区分程序中创建的套接字而分配给套接字的序号。 #### 3.1.1 网络地址(Internet Address) 为使计算机连接到网络并收发数据,必须为其分配 IP 地址。IP 地址分为两类。 -- IPV4(Internet Protocol version 4)4 字节地址族 -- IPV6(Internet Protocol version 6)6 字节地址族 +- IPv4(Internet Protocol version 4)4 字节地址族 +- IPv6(Internet Protocol version 6)16 字节地址族 -两者之间的主要差别是 IP 地址所用的字节数,目前通用的是 IPV4 , IPV6 的普及还需要时间。 +两者之间的主要差别是 IP 地址所用的字节数,目前通用的是 IPv4,IPv6 的普及还需要时间。 -IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机)地址,且分为 A、B、C、D、E 等类型。 +IPv4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机)地址,且分为 A、B、C、D、E 等类型。 ![](https://i.loli.net/2019/01/13/5c3ab0eb17bbe.png) @@ -490,11 +680,11 @@ IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机) ![](https://i.loli.net/2019/01/13/5c3ab19174fa4.png) -某主机向 203.211.172.103 和 203.211.217.202 传递数据,其中 203.211.172 和 203.211.217 为该网络的网络地址,所以「向相应网络传输数据」实际上是向构成网络的路由器或者交换机传输数据,然后又路由器或者交换机根据数据中的主机地址向目标主机传递数据。 +某主机向 203.211.172.103 和 203.211.217.202 传递数据,其中 203.211.172 和 203.211.217 为该网络的网络地址,所以「向相应网络传输数据」实际上是向构成网络的路由器或者交换机传输数据,然后由路由器或者交换机根据数据中的主机地址向目标主机传递数据。 #### 3.1.2 网络地址分类与主机地址边界 -只需通过IP地址的第一个字节即可判断网络地址占用的总字节数,因为我们根据IP地址的边界区分网络地址,如下所示: +只需通过 IP 地址的第一个字节即可判断网络地址占用的总字节数,因为我们根据 IP 地址的边界区分网络地址,如下所示: - A 类地址的首字节范围为:0~127 - B 类地址的首字节范围为:128~191 @@ -503,28 +693,28 @@ IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机) 还有如下这种表示方式: - A 类地址的首位以 0 开始 -- B 类地址的前2位以 10 开始 -- C 类地址的前3位以 110 开始 +- B 类地址的前 2 位以 10 开始 +- C 类地址的前 3 位以 110 开始 -因此套接字手法数据时,数据传到网络后即可轻松找到主机。 +因此套接字收发数据时,数据传到网络后即可轻松找到主机。 #### 3.1.3 用于区分套接字的端口号 -IP地址用于区分计算机,只要有IP地址就能向目标主机传输数据,但是只有这些还不够,我们需要把信息传输给具体的应用程序。 +IP 地址用于区分计算机,只要有 IP 地址就能向目标主机传输数据,但是只有这些还不够,我们需要把信息传输给具体的应用程序。 -所以计算机一般有 NIC(网络接口卡)数据传输设备。通过 NIC 接受的数据内有端口号,操作系统参考端口号把信息传给相应的应用程序。 +所以计算机一般有 NIC(网络接口卡)数据传输设备。通过 NIC 接收的数据内有端口号,操作系统参考端口号把信息传给相应的应用程序。 -端口号由 16 位构成,可分配的端口号范围是 0~65535 。但是 0~1023 是知名端口,一般分配给特定的应用程序,所以应当分配给此范围之外的值。 +端口号由 16 位构成,可分配的端口号范围是 0~65535。但是 0~1023 是知名端口,一般分配给特定的应用程序,所以应当分配给此范围之外的值。 -虽然端口号不能重复,但是 TCP 套接字和 UDP 套接字不会共用端接口号,所以允许重复。如果某 TCP 套接字使用了 9190 端口号,其他 TCP 套接字就无法使用该端口号,但是 UDP 套接字可以使用。 +虽然端口号不能重复,但是 TCP 套接字和 UDP 套接字不会共用端口号,所以允许重复。如果某 TCP 套接字使用了 9190 端口号,其他 TCP 套接字就无法使用该端口号,但是 UDP 套接字可以使用。 -总之,数据传输目标地址同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序。 +总之,数据传输目标地址同时包含 IP 地址和端口号,只有这样,数据才会被传输到最终的目的应用程序。 ### 3.2 地址信息的表示 -应用程序中使用的IP地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。 +应用程序中使用的 IP 地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。 -#### 3.2.1 表示 IPV4 地址的结构体 +#### 3.2.1 表示 IPv4 地址的结构体 结构体的定义如下 @@ -538,20 +728,20 @@ struct sockaddr_in }; ``` -该结构体中提到的另一个结构体 in_addr 定义如下,它用来存放 32 位IP地址 +该结构体中提到的另一个结构体 in_addr 定义如下,它用来存放 32 位 IP 地址 ```c struct in_addr { - in_addr_t s_addr; //32位IPV4地址 -} + in_addr_t s_addr; //32位IPv4地址 +}; ``` -关于以上两个结构体的一些数据类型。 +关于以上两个结构体的一些数据类型: | 数据类型名称 | 数据类型说明 | 声明的头文件 | | :----------: | :----------------------------------: | :----------: | -| int 8_t | signed 8-bit int | sys/types.h | +| int8_t | signed 8-bit int | sys/types.h | | uint8_t | unsigned 8-bit int (unsigned char) | sys/types.h | | int16_t | signed 16-bit int | sys/types.h | | uint16_t | unsigned 16-bit int (unsigned short) | sys/types.h | @@ -559,26 +749,26 @@ struct in_addr | uint32_t | unsigned 32-bit int (unsigned long) | sys/types.h | | sa_family_t | 地址族(address family) | sys/socket.h | | socklen_t | 长度(length of struct) | sys/socket.h | -| in_addr_t | IP地址,声明为 uint_32_t | netinet/in.h | -| in_port_t | 端口号,声明为 uint_16_t | netinet/in.h | +| in_addr_t | IP地址,声明为 uint32_t | netinet/in.h | +| in_port_t | 端口号,声明为 uint16_t | netinet/in.h | -为什么要额外定义这些数据类型呢?这是考虑扩展性的结果 +为什么要额外定义这些数据类型呢?这是考虑扩展性的结果。 #### 3.2.2 结构体 sockaddr_in 的成员分析 - 成员 sin_family -每种协议适用的地址族不同,比如,IPV4 使用 4 字节的地址族,IPV6 使用 16 字节的地址族。 +每种协议适用的地址族不同,比如,IPv4 使用 4 字节的地址族,IPv6 使用 16 字节的地址族。 > 地址族 | 地址族(Address Family) | 含义 | | ------------------------ | ---------------------------------- | -| AF_INET | IPV4用的地址族 | -| AF_INET6 | IPV6用的地址族 | +| AF_INET | IPv4 用的地址族 | +| AF_INET6 | IPv6 用的地址族 | | AF_LOCAL | 本地通信中采用的 Unix 协议的地址族 | -AF_LOACL 只是为了说明具有多种地址族而添加的。 +AF_LOCAL 只是为了说明具有多种地址族而添加的。 - 成员 sin_port @@ -586,35 +776,34 @@ AF_LOACL 只是为了说明具有多种地址族而添加的。 - 成员 sin_addr - 该成员保存 32 为IP地址信息,且也以网络字节序保存 + 该成员保存 32 位 IP 地址信息,且也以网络字节序保存。 - 成员 sin_zero - 无特殊含义。只是为结构体 sockaddr_in 的大小与sockaddr结构体保持一致而插入的成员。填充零。 - - 在之前的代码中 sockaddr_in 结构体变量地址值将以如下方式传递给 bind 函数。 + 无特殊含义。只是为结构体 sockaddr_in 结构体变量地址值将以如下方式传递给 bind 函数。 + + 在之前的代码中 ```c if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) error_handling("bind() error"); ``` - 此处 bind 第二个参数期望得到的是 sockaddr 结构体变量的地址值,包括地址族、端口号、IP地址等。 + 此处 bind 第二个参数期望得到的是 sockaddr 结构体变量的地址值,包括地址族、端口号、IP 地址等。 ```c struct sockaddr { sa_family_t sin_family; //地址族 char sa_data[14]; //地址信息 - } + }; ``` - sockaddr_in结构体作用:由于直接让结构体sockadd包含所需信息比较麻烦,所以用sockaddr_in结构体强制转换 - 此结构体 sa_data 保存的地址信息中需要包含IP地址和端口号,剩余部分应该填充 0 ,但是这样对于包含地址的信息非常麻烦,所以出现了 sockaddr_in 结构体,然后强制转换成 sockaddr 类型,则生成符合 bind 条件的参数。 + 此结构体 sa_data 保存的地址信息中需要包含 IP 地址和端口号,剩余部分应该填充 0,但是这样对于包含地址的信息非常麻烦,所以出现了 sockaddr_in 结构体,然后强制转换成 sockaddr 类型,则生成符合 bind 条件的参数。 ### 3.3 网络字节序与地址变换 -不同的 CPU 中,4 字节整数值1在内存空间保存方式是不同的。 +不同的 CPU 中,4 字节整数值 1 在内存空间保存方式是不同的。 有些 CPU 这样保存: @@ -628,14 +817,14 @@ AF_LOACL 只是为了说明具有多种地址族而添加的。 00000001 00000000 00000000 00000000 ``` -两种一种是顺序保存,一种是倒序保存 。 +两种一种是顺序保存,一种是倒序保存。 #### 3.3.1 字节序(Order)与网络字节序 CPU 保存数据的方式有两种,这意味着 CPU 解析数据的方式也有 2 种: - 大端序(Big Endian):高位字节存放到低位地址 -- 小端序(Little Endian):高位字节存放到高位地址 +- 小端序(Little Endian):低位字节存放到低位地址 ![big.png](https://i.loli.net/2019/01/13/5c3ac9c1b2550.png) ![small.png](https://i.loli.net/2019/01/13/5c3ac9c1c3348.png) @@ -644,7 +833,7 @@ CPU 保存数据的方式有两种,这意味着 CPU 解析数据的方式也 ![zijiexu.png](https://i.loli.net/2019/01/13/5c3aca956c8e9.png) -因为这种原因,所以在通过网络传输数据时必须约定统一的方式,这种约定被称为网络字节序,非常简单,统一为大端序。即,先把数据数组转化成大端序格式再进行网络传输。 +因为这种原因,所以在通过网络传输数据时必须约定统一的方式,这种约定被称为网络字节序(Network Byte Order),非常简单,统一为大端序。即,先把数据数组转化成大端序格式再进行网络传输。 #### 3.3.2 字节序转换 @@ -661,14 +850,14 @@ unsigned long ntohl(unsigned long); - htons 的 h 代表主机(host)字节序。 - htons 的 n 代表网络(network)字节序。 -- s 代表 short -- l 代表 long +- s 代表两个字节的 short 类型,因此以 s 为后缀的函数用于端口转换。 +- l 代表四个字节的 long 类型,所以以 l 为后缀的函数用于 IP 地址转换。 下面的代码是示例,说明以上函数调用过程: [endian_conv.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch03/endian_conv.c) - -```cpp + +```c #include #include int main(int argc, char *argv[]) @@ -712,11 +901,12 @@ Network ordered address: 0x78563412 #### 3.4.1 将字符串信息转换为网络字节序的整数型 -sockaddr_in 中需要的是 32 位整数型,但是我们只熟悉点分十进制表示法,那么改如何把类似于 201.211.214.36 转换为 4 字节的整数类型数据呢 ?幸运的是,有一个函数可以帮助我们完成它。 +sockaddr_in 中需要的是 32 位整数型,但是我们只熟悉点分十进制表示法,那么该如何把类似于 201.211.214.36 转换为 4 字节的整数类型数据呢?幸运的是,有一个函数可以帮助我们完成它,该函数将字符串形式的 IP 地址转换为网络字节序形式的 32 位整数型数据。 -```C +```c #include in_addr_t inet_addr(const char *string); +//成功时返回 32 位大端序整数型值,失败时返回 INADDR_NONE ``` 具体示例: @@ -760,9 +950,9 @@ Network ordered integer addr: 0x4030201 Error occured! ``` -1个字节能表示的最大整数是255,所以代码中 addr2 是错误的IP地址。从运行结果看,inet_addr 不仅可以转换地址,还可以检测有效性。 +1 个字节能表示的最大整数是 255,所以代码中 addr2 是错误的 IP 地址。从运行结果看,inet_addr 不仅可以转换地址,还可以检测有效性。 -inet_aton 函数与 inet_addr 函数在功能上完全相同,也是将字符串形式的IP地址转换成整数型的IP地址。只不过该函数用了 in_addr 结构体,且使用频率更高。 +inet_aton 函数与 inet_addr 函数在功能上完全相同,也是将字符串形式的 IP 地址转换成整数型的 IP 地址。只不过该函数用了 in_addr 结构体,且使用频率更高。 ```c #include @@ -770,7 +960,7 @@ int inet_aton(const char *string, struct in_addr *addr); /* 成功时返回 1 ,失败时返回 0 string: 含有需要转换的IP地址信息的字符串地址值 -addr: 将保存转换结果的 in_addr 结构体变量的地址值 +addr: 保存转换结果的 in_addr 结构体变量的地址值 */ ``` @@ -806,7 +996,7 @@ void error_handling(char *message) 编译运行: -```c +```shell gcc inet_aton.c -o aton ./aton ``` @@ -819,14 +1009,15 @@ Network ordered integer addr: 0x4f7ce87f 可以看出,已经成功的把转换后的地址放进了 addr_inet.sin_addr.s_addr 中。 -还有一个函数,与 inet_aton() 正好相反,它可以把网络字节序整数型IP地址转换成我们熟悉的字符串形式,函数原型如下: +还有一个函数,与 inet_aton() 正好相反,它可以把网络字节序整数型 IP 地址转换成我们熟悉的字符串形式,函数原型如下: ```c #include char *inet_ntoa(struct in_addr adr); +//成功时返回保存转换结果的字符串地址值,失败时返回 NULL 空指针 ``` -该函数将通过参数传入的整数型IP地址转换为字符串格式并返回。但要小心,返回值为 char 指针,返回字符串地址意味着字符串已经保存在内存空间,但是该函数未向程序员要求分配内存,而是再内部申请了内存保存了字符串。也就是说调用了该函数候要立即把信息复制到其他内存空间。因此,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之,再次调用 inet_ntoa 函数前返回的字符串地址是有效的。若需要长期保存,则应该将字符串复制到其他内存空间。 +该函数将通过参数传入的整数型 IP 地址转换为字符串格式并返回。但要小心,返回值为 char 指针,返回字符串地址意味着字符串已经保存在内存空间,但是该函数未向程序员要求分配内存,而是在内部申请了内存保存了字符串。也就是说调用了该函数后要立即把信息复制到其他内存空间。因为,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之,再次调用 inet_ntoa 函数前返回的字符串地址是有效的。若需要长期保存,则应该将字符串复制到其他内存空间。 示例: @@ -864,9 +1055,9 @@ gcc inet_ntoa.c -o ntoa ./ntoa ``` -输出: +输出: -```c +``` Dotted-Decimal notation1: 1.2.3.4 Dotted-Decimal notation2: 1.1.1.1 Dotted-Decimal notation3: 1.2.3.4 @@ -878,7 +1069,7 @@ Dotted-Decimal notation3: 1.2.3.4 ```c struct sockaddr_in addr; -char *serv_ip = "211.217,168.13"; //声明IP地址族 +char *serv_ip = "211.217.168.13"; //声明IP地址族 char *serv_port = "9190"; //声明端口号字符串 memset(&addr, 0, sizeof(addr)); //结构体变量 addr 的所有成员初始化为0 addr.sin_family = AF_INET; //制定地址族 @@ -894,33 +1085,35 @@ addr.sin_port = htons(atoi(serv_port)); //基于字符串的IP地址端口号 > 答案仅代表本人个人观点,不一定正确 -1. **IP地址族 IPV4 与 IPV6 有什么区别?在何种背景下诞生了 IPV6?** +1. **IP 地址族 IPv4 与 IPv6 有什么区别?在何种背景下诞生了 IPv6?** - 答:主要差别是IP地址所用的字节数,目前通用的是IPV4,目前IPV4的资源已耗尽,所以诞生了IPV6,它具有更大的地址空间。 + 答:主要差别是 IP 地址所用的字节数,IPv4 使用 4 字节地址(约 43 亿个地址),IPv6 使用 16 字节地址(约 3.4×10^38 个地址)。目前通用的是 IPv4,但由于 IPv4 地址资源已近枯竭,所以诞生了 IPv6,它具有巨大的地址空间,可以满足未来互联网发展的需求。 -2. **通过 IPV4 网络 ID 、主机 ID 及路由器的关系说明公司局域网的计算机传输数据的过程** +2. **通过 IPv4 网络 ID、主机 ID 及路由器的关系说明公司局域网的计算机传输数据的过程。** - 答:网络ID是为了区分网络而设置的一部分IP地址,假设向`www.baidu.com`公司传输数据,该公司内部构建了局域网。因为首先要向`baidu.com`传输数据,也就是说并非一开始就浏览所有四字节IP地址,首先找到网络地址,进而由`baidu.com`(构成网络的路由器)接收到数据后,传输到主机地址。比如向 203.211.712.103 传输数据,那就先找到 203.211.172 然后由这个网络的网关找主机号为 172 的机器传输数据。 + 答:网络 ID 是为了区分网络而设置的一部分 IP 地址,主机 ID 是为了区分网络内的主机而设置的部分。假设向某公司传输数据,该公司内部构建了局域网。数据传输时首先根据网络 ID 找到目标网络(由路由器或网关接收),然后由该网络的路由器根据主机 ID 将数据转发给具体的主机。 -3. **套接字地址分为IP地址和端口号,为什么需要IP地址和端口号?或者说,通过IP地址可以区分哪些对象?通过端口号可以区分哪些对象?** + 例如向 IP 地址 203.211.172.103 传输数据,其中 203.211.172 是网络 ID,103 是主机 ID。数据首先被路由到网络 203.211.172(由该网络的网关路由器接收),然后路由器根据主机 ID 103 将数据传递给局域网内对应的主机。 - 答:有了IP地址和端口号,才能把数据准确的传送到某个应用程序中。通过IP地址可以区分具体的主机,通过端口号可以区分主机上的应用程序。 +3. **套接字地址分为 IP 地址和端口号,为什么需要 IP 地址和端口号?或者说,通过 IP 地址可以区分哪些对象?通过端口号可以区分哪些对象?** -4. **请说明IP地址的分类方法,并据此说出下面这些IP的分类。** + 答:有了 IP 地址和端口号,才能把数据准确地传送到某个应用程序中。通过 IP 地址可以区分网络中不同的主机(计算机),通过端口号可以区分同一主机上不同的应用程序(套接字)。 - - 214.121.212.102(C类) - - 120.101.122.89(A类) - - 129.78.102.211(B类) +4. **请说明 IP 地址的分类方法,并据此说出下面这些 IP 的分类。** - 分类方法:A 类地址的首字节范围为:0~127、B 类地址的首字节范围为:128~191、C 类地址的首字节范围为:192~223 + - 214.121.212.102(C 类) + - 120.101.122.89(A 类) + - 129.78.102.211(B 类) + + 分类方法:A 类地址的首字节范围为:0~127、B 类地址的首字节范围为:128~191、C 类地址的首字节范围为:192~223。 5. **计算机通过路由器和交换机连接到互联网,请说出路由器和交换机的作用。** - 答:路由器和交换机完成外网和本网主机之间的数据交换。 + 答:路由器用于连接不同的网络,负责在不同网络间转发数据包,根据 IP 地址的网络 ID 进行路由选择。交换机用于组织局域网内部的主机连接,局域网内部的主机可以通过交换机直接通信(基于 MAC 地址)。如果局域网内的主机想要和其他网络的主机通信,需要通过路由器转发到目的网络;接收到的其他网络发来的数据先由路由器接收,再由路由器转发到交换机,最终找到具体的主机。 6. **什么是知名端口?其范围是多少?知名端口中具有代表性的 HTTP 和 FTP 的端口号各是多少?** - 答:知名端口是要把该端口分配给特定的应用程序,范围是 0~1023 ,HTTP 的端口号是 80 ,FTP 的端口号是20和21 + 答:知名端口(Well-Known Ports)是保留给特定应用程序使用的端口,范围是 0~1023。HTTP 的默认端口号是 80,FTP 的默认端口号是 20(数据连接)和 21(控制连接)。 7. **向套接字分配地址的 bind 函数原型如下:** @@ -931,26 +1124,36 @@ addr.sin_port = htons(atoi(serv_port)); //基于字符串的IP地址端口号 **而调用时则用:** ```c - bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr) + bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)); ``` **此处 serv_addr 为 sockaddr_in 结构体变量。与函数原型不同,传入的是 sockaddr_in 结构体变量,请说明原因。** - 答:因为对于详细的地址信息使用 sockaddr 类型传递特别麻烦,进而有了 sockaddr_in 类型,其中基本与前面的类型保持一致,还有 sa_sata[4] 来保存地址信息,剩余全部填 0,所以强制转换后,不影响程序运行。 + 答:sockaddr 结构体的 sa_data 成员是一个 14 字节的数组,需要手动填充 IP 地址、端口号等信息,使用起来非常麻烦。因此设计了 sockaddr_in 结构体,它将地址信息分成了 sin_family、sin_port、sin_addr 等独立的成员,便于使用。由于 sockaddr_in 和 sockaddr 在内存布局上是兼容的(大小相同,前两个字段偏移一致),且 bind 函数需要的是 sockaddr 类型的指针,所以可以将 sockaddr_in 结构体变量的地址强制转换为 sockaddr* 类型传入,不影响程序的正确运行。 -8. **请解释大端序,小端序、网络字节序,并说明为何需要网络字节序。** +8. **请解释大端序、小端序、网络字节序,并说明为何需要网络字节序。** - 答:CPU 向内存保存数据有两种方式,大端序是高位字节存放低位地址,小端序是高位字节存放高位地址,网络字节序是为了方便传输的信息统一性,统一成了大端序。 + 答: + - 大端序(Big Endian):高位字节存放在低位地址 + - 小端序(Little Endian):低位字节存放在低位地址 + - 网络字节序:为了在网络上统一传输数据的格式,约定使用大端序作为网络字节序 + + 需要网络字节序的原因:不同的 CPU 架构采用不同的字节序(如 x86 是小端序,PowerPC 是大端序),如果两台字节序不同的计算机直接传输数据,接收方会错误解析数据。因此约定网络传输统一使用大端序,发送前将主机字节序转换为网络字节序,接收后再转换回主机字节序。 9. **大端序计算机希望把 4 字节整数型 12 传递到小端序计算机。请说出数据传输过程中发生的字节序变换过程。** - 答:'0x12->0x21' - 更正:0x0000000c->0x0000000c->0x0c000000 + 答:整数 12 的十六进制表示为 0x0000000C。 -10. **怎样表示回送地址?其含义是什么?如果向会送地址处传输数据将会发生什么情况?** + - 在大端序计算机上,内存表示为:`00 00 00 0C`(从低地址到高地址) + - 发送时已经是网络字节序(大端序),无需转换,直接发送 `00 00 00 0C` + - 小端序计算机接收到 `00 00 00 0C` 后,通过 ntohl() 函数转换为小端序 + - 转换后在小端序计算机内存中为:`0C 00 00 00`(从低地址到高地址) - 答:127.0.0.1 表示回送地址,指的是计算机自身的IP地址,无论什么程序,一旦使用回送地址发送数据,协议软件立即返回,不进行任何网络传输。 + 注意:字节序转换函数处理的是整个 4 字节整数,而非单字节。对于单字节值 12(0x0C),无论在大端序还是小端序机器上,其值都是 0x0C,不会变成 0x21。 +10. **怎样表示回送地址?其含义是什么?如果向回送地址处传输数据将会发生什么情况?** + + 答:回送地址表示为 127.0.0.1(IPv4)或 ::1(IPv6),指的是计算机自身的 IP 地址,也称为本地环回地址。无论什么程序,一旦使用回送地址发送数据,数据不会真正发送到网络上,而是在本机协议栈内循环,协议软件立即将数据返回给本机的接收端,不进行任何网络传输。回送地址常用于本地服务测试和网络程序调试。 ## 第 4 章 基于 TCP 的服务端/客户端(1) 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -973,17 +1176,17 @@ TCP/IP 协议栈共分为 4 层,可以理解为数据收发分成了 4 个层 #### 4.1.3 IP 层 -转备好物理连接候就要传输数据。为了再复杂网络中传输数据,首先要考虑路径的选择。向目标传输数据需要经过哪条路径?解决此问题的就是IP层,该层使用的协议就是IP。 +准备好物理连接后就要传输数据。为了在复杂网络中传输数据,首先要考虑路径的选择。向目标传输数据需要经过哪条路径?解决此问题的就是IP层,该层使用的协议就是IP。 IP 是面向消息的、不可靠的协议。每次传输数据时会帮我们选择路径,但并不一致。如果传输过程中发生错误,则选择其他路径,但是如果发生数据丢失或错误,则无法解决。换言之,IP协议无法应对数据错误。 #### 4.1.4 TCP/UDP 层 -IP 层解决数据传输中的路径选择问题,只需照此路径传输数据即可。TCP 和 UDP 层以 IP 层提供的路径信息为基础完成实际的数据传输,故该层又称为传输层。UDP 比 TCP 简单,现在我们只解释 TCP 。 TCP 可以保证数据的可靠传输,但是它发送数据时以 IP 层为基础(这也是协议栈层次化的原因) +IP 层解决数据传输中的路径选择问题,只需照此路径传输数据即可。TCP 和 UDP 层以 IP 层提供的路径信息为基础完成实际的数据传输,故该层又称为传输层。UDP 比 TCP 简单,现在我们只解释 TCP 。 TCP 可以保证数据的可靠传输,但是它发送数据时以 IP 层为基础(这也是协议栈层次化的原因)。 IP 层只关注一个数据包(数据传输基本单位)的传输过程。因此,即使传输多个数据包,每个数据包也是由 IP 层实际传输的,也就是说传输顺序及传输本身是不可靠的。若只利用IP层传输数据,则可能导致后传输的数据包B比先传输的数据包A提早到达。另外,传输的数据包A、B、C中可能只收到A和C,甚至收到的C可能已经损毁 。反之,若添加 TCP 协议则按照如下对话方式进行数据交换。 -> 主机A:正确接受第二个数据包 +> 主机A:正确接收第二个数据包 > > 主机B:恩,知道了 > @@ -1009,7 +1212,7 @@ IP 层只关注一个数据包(数据传输基本单位)的传输过程。 #### 4.2.2 进入等待连接请求状态 -已经调用了 bind 函数给套接字分配地址,接下来就是要通过调用 listen 函数进入等待连接请求状态。只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能调用 connect 函数 +已经调用了 bind 函数给套接字分配地址,接下来就是要通过调用 listen 函数进入等待连接请求状态。只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。客户端可以调用 connect 函数,向服务端请求连接,对于客户端发来的请求,先进入连接请求等待队列,等待服务端受理请求。 ```c #include @@ -1020,7 +1223,7 @@ int listen(int sockfd, int backlog); ``` #### 4.2.3 受理客户端连接请求 -调用 listen 函数后,则应该按序受理。受理请求意味着可接受数据的状态。进入这种状态所需的部件是**套接字**,但是此时使用的不是服务端套接字,此时需要另一个套接字,但是没必要亲自创建,下面的函数将自动创建套接字。 +调用 listen 函数后,套接字应该按序受理客户端发起的连接请求。受理请求就是服务端处理一个连接请求,进入可接受客户端数据的状态。进入这种状态所需的部件是**套接字**,但是此时使用的不是服务端套接字,此时需要另一个套接字,但是没必要亲自创建,下面的函数将自动创建套接字。 ```c #include @@ -1028,12 +1231,14 @@ int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); /* 成功时返回文件描述符,失败时返回-1 sock: 服务端套接字的文件描述符 -addr: 保存发起连接请求的客户端地址信息的变量地址值 -addrlen: 的第二个参数addr结构体的长度,但是存放有长度的变量地址。 +addr: 受理的请求中,客户端地址信息会保存到该指针指向的地址 +addrlen: 该指针指向的地址中保存第二个参数的结构体长度 */ ``` -sccept 函数受理连接请求队列中待处理的客户端连接请求。函数调用成功后,accept 内部将产生用于数据 I/O 的套接字,并返回其文件描述符。需要强调的是套接字是自动创建的,并自动与发起连接请求的客户端建立连接。 +accept 函数受理连接请求队列中待处理的客户端连接请求。函数调用成功后,accept 内部将产生用于数据 I/O 的套接字,并返回其文件描述符。需要强调的是套接字是自动创建的,并自动与发起连接请求的客户端建立连接。 + +注意:accept 函数返回的套接字不等于服务端套接字,也需要通过 close 函数关闭。 #### 4.2.4 回顾 Hello World 服务端 @@ -1051,7 +1256,7 @@ sccept 函数受理连接请求队列中待处理的客户端连接请求。函 ![](https://i.loli.net/2019/01/14/5c3c31d77e86c.png) -与服务端相比,区别就在于「请求连接」,他是创建客户端套接字后向服务端发起的连接请求。服务端调用 listen 函数后创建连接请求等待队列,之后客户端即可请求连接。 +与服务端相比,区别就在于「请求连接」,它是创建客户端套接字后向服务端发起的连接请求。服务端调用 listen 函数后创建连接请求等待队列,之后客户端即可请求连接。 ```c #include @@ -1060,17 +1265,19 @@ int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen); 成功时返回0,失败返回-1 sock:客户端套接字文件描述符 servaddr: 保存目标服务器端地址信息的变量地址值 -addrlen: 以字节为单位传递给第二个结构体参数 servaddr 的变量地址长度 +addrlen: 第二个结构体参数 servaddr 变量的字节长度 */ ``` -客户端调用 connect 函数候,发生以下函数之一才会返回(完成函数调用): +客户端调用 connect 函数后,发生以下函数之一才会返回(完成函数调用): - 服务端接受连接请求 -- 发生断网等一场状况而中断连接请求 +- 发生断网等异常状况而中断连接请求 注意:**接受连接**不代表服务端调用 accept 函数,其实只是服务器端把连接请求信息记录到等待队列。因此 connect 函数返回后并不应该立即进行数据交换。 +客户端在调用 connect 函数时自动分配主机的 IP,随机分配端口。无需调用显式的 bind 函数进行分配。 + #### 4.2.6 回顾 Hello World 客户端 - 代码:[hello_client.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch04/hello_client.c) @@ -1081,13 +1288,16 @@ addrlen: 以字节为单位传递给第二个结构体参数 servaddr 的变量 2. 结构体变量 serv_addr 中初始化IP和端口信息。初始化值为目标服务器端套接字的IP和端口信息。 3. 调用 connect 函数向服务端发起连接请求 4. 完成连接后,接收服务端传输的数据 -5. 接收数据后调用 close 函数关闭套接字,结束与服务器端的连接。 +5. 接收数据后调用 close 函数关闭套接字,结束与服务器端的连接。(对套接字调用close函数,对应于向建立连接的对应套接字发送EOF。即,如果客户端的套接字调用了close函数,服务端read时候会返回0。) #### 4.2.7 基于 TCP 的服务端/客户端函数调用关系 +关系图如下所示: + ![](https://i.loli.net/2019/01/14/5c3c35a773b8c.png) -关系如上图所示。 +- 客户端只能等到服务端调用 listen 函数后才能调用 connect 函数 +- 服务器端可能会在客户端调用 connect 之前调用 accept 函数,这时服务器端进入阻塞(blocking)状态,直到客户端调用 connect 函数后接收到连接请求。 ### 4.3 实现迭代服务端/客户端 @@ -1108,7 +1318,7 @@ addrlen: 以字节为单位传递给第二个结构体参数 servaddr 的变量 - 服务器端在同一时刻只与一个客户端相连,并提供回声服务。 - 服务器端依次向 5 个客户端提供服务并退出。 - 客户端接受用户输入的字符串并发送到服务器端。 -- 服务器端将接受的字符串数据传回客户端,即「回声」 +- 服务器端将接收的字符串数据传回客户端,即「回声」 - 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。 以下是服务端与客户端的代码: @@ -1142,7 +1352,7 @@ client: #### 4.3.3 回声客户端存在的问题 -以上代码有一个假设「每次调用 read、write函数时都会以字符串为单位执行实际 I/O 操作」 +以上客户端代码有一个假设「每次调用 read、write函数时都会以字符串为单位执行实际 I/O 操作」 但是「第二章」中说过「TCP 不存在数据边界」,上述客户端是基于 TCP 的,因此多次调用 write 函数传递的字符串有可能一次性传递到服务端。此时客户端有可能从服务端收到多个字符串,这不是我们想要的结果。还需要考虑服务器的如下情况: @@ -1154,7 +1364,21 @@ client: ### 4.4 基于 Windows 的实现 -暂略 +Windows 平台下的 Socket 编程(Winsock)与 Linux 平台基本类似,主要区别如下: + +1. **头文件**:Windows 使用 `winsock2.h` 和 `ws2tcpip.h`,Linux 使用 `sys/socket.h` 等头文件。 + +2. **库文件**:Windows 需要链接 `ws2_32.lib` 库。 + +3. **初始化**:Windows 使用 Winsock 函数前需要调用 `WSAStartup` 进行初始化,程序结束前需要调用 `WSACleanup` 清理。 + +4. **套接字类型**:Windows 中 `SOCKET` 类型是 `HANDLE`(句柄),而 Linux 中是 `int`(文件描述符)。Windows 的 `INVALID_SOCKET` 对应 Linux 的 `-1`。 + +5. **关闭套接字**:Windows 使用 `closesocket`,Linux 使用 `close`。 + +6. **错误处理**:Windows 使用 `WSAGetLastError()` 获取错误码,Linux 使用 `errno` 全局变量。 + +7. **函数返回值**:大部分函数返回值含义相同,但 Windows 中部分函数的返回类型可能不同(如 `recv` 返回 `int` 而非 `ssize_t`)。 ### 4.5 习题 @@ -1170,7 +1394,7 @@ client: 3. **为何需要把 TCP/IP 协议栈分成 4 层(或7层)?开放式回答。** - 答:ARPANET 的研制经验表明,对于复杂的计算机网络协议,其结构应该是层次式的。分层的好处:①隔层之间是独立的②灵活性好③结构上可以分隔开④易于实现和维护⑤能促进标准化工作。 + 答:ARPANET 的研制经验表明,对于复杂的计算机网络协议,其结构应该是层次式的。分层的好处:①层与层之间是独立的②灵活性好③结构上可以分隔开④易于实现和维护⑤能促进标准化工作。 4. **客户端调用 connect 函数向服务器端发送请求。服务器端调用哪个函数后,客户端可以调用 connect 函数?** @@ -1178,17 +1402,16 @@ client: 5. **什么时候创建连接请求等待队列?它有何种作用?与 accept 有什么关系?** - 答:服务端调用 listen 函数后,accept函数正在处理客户端请求时, 更多的客户端发来了请求连接的数据,此时,就需要创建连接请求等待队列。以便于在accept函数处理完手头的请求之后,按照正确的顺序处理后面正在排队的其他请求。与accept函数的关系:accept函数受理连接请求等待队列中待处理的客户端连接请求。 + 答:服务端调用 `listen` 函数时创建连接请求等待队列。作用:当多个客户端几乎同时向服务端发起连接请求时,服务端在调用 `accept` 函数处理当前请求的同时,后续的连接请求会进入该队列等待。与 `accept` 函数的关系:`accept` 函数从连接请求等待队列的队头取出一个连接请求进行处理,如果队列为空则 `accept` 函数会阻塞等待。 6. **客户端中为何不需要调用 bind 函数分配地址?如果不调用 bind 函数,那何时、如何向套接字分配IP地址和端口号?** - 答:在调用 connect 函数时分配了地址,客户端IP地址和端口在调用 connect 函数时自动分配,无需调用标记的 bind 函数进行分配。 - + 答:客户端通常不需要显式调用 `bind` 函数分配地址。如果不调用 `bind` 函数,在调用 `connect` 函数时,操作系统会自动为客户端套接字分配 IP 地址(使用本机网络接口的 IP)和端口号(从临时端口范围中随机选择一个未使用的端口)。 ## 第 5 章 基于 TCP 的服务端/客户端(2) 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 -上一章仅仅是从编程角度学习实现方法,并未详细讨论 TCP 的工作原理。因此,本章将想次讲解 TCP 中必要的理论知识,还将给出第 4 章客户端问题的解决方案。 +上一章仅仅是从编程角度学习实现方法,并未详细讨论 TCP 的工作原理。因此,本章将详细讲解 TCP 中必要的理论知识,还将给出第 4 章客户端问题的解决方案。 ### 5.1 回声客户端的完美实现 @@ -1201,14 +1424,14 @@ while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0) write(clnt_sock, message, str_len); ``` -接着是客户端代码: +接着是客户端代码: ```c write(sock, message, strlen(message)); str_len = read(sock, message, BUF_SIZE - 1); ``` -二者都在村换调用 read 和 write 函数。实际上之前的回声客户端将 100% 接受自己传输的数据,只不过接受数据时的单位有些问题。扩展客户端代码回顾范围,下面是,客户端的代码: +二者都在循环调用 read 和 write 函数。实际上之前的回声客户端将 100% 接收字节传输的数据,只不过接收数据时的单位有些问题。扩展客户端代码回顾范围,下面是客户端的代码: ```c while (1) @@ -1226,11 +1449,11 @@ while (1) } ``` -现在应该理解了问题,回声客户端传输的是字符串,而且是通过调用 write 函数一次性发送的。之后还调用一次 read 函数,期待着接受自己传输的字符串,这就是问题所在。 +现在应该理解了问题,回声客户端传输的是字符串,而且是通过调用 write 函数一次性发送的。之后还调用一次 read 函数,期待着接收自己传输的字符串,这就是问题所在。 #### 5.1.2 回声客户端问题的解决办法 -这个问题其实很容易解决,因为可以提前接受数据的大小。若之前传输了20字节长的字符串,则再接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,那么代码如下: +这个问题其实很容易解决,因为可以提前接收数据的大小。若之前传输了 20 字节长的字符串,则在接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,那么代码如下: - [echo_client2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch05/echo_client2.c) @@ -1238,20 +1461,20 @@ while (1) #### 5.1.3 如果问题不在于回声客户端:定义应用层协议 -回声客户端可以提前知道接收数据的长度,这在大多数情况下是不可能的。那么此时无法预知接收数据长度时应该如何手法数据?这是需要的是**应用层协议**的定义。在收发过程中定好规则(协议)以表示数据边界,或者提前告知需要发送的数据的大小。服务端/客户端实现过程中逐步定义的规则集合就是应用层协议。 +回声客户端可以提前知道接收数据的长度,这在大多数情况下是不可能的。那么此时无法预知接收数据长度时应该如何收发数据?这时需要的是**应用层协议**的定义。在收发过程中定好规则(协议)以表示数据边界,或者提前告知需要发送的数据的大小。服务端/客户端实现过程中逐步定义的规则集合就是应用层协议。 现在写一个小程序来体验应用层协议的定义过程。要求: 1. 服务器从客户端获得多个数组和运算符信息。 -2. 服务器接收到数字后对其进行加减乘运算,然后把结果传回客户端。 +2. 服务器接收到数字后对齐进行加减乘运算,然后把结果传回客户端。 例: -1. 向服务器传递3,5,9的同时请求加法运算,服务器返回3+5+9的结果 +1. 向服务器传递 3, 5, 9 的同时请求加法运算,服务器返回 3+5+9 的结果 2. 请求做乘法运算,客户端会收到`3*5*9`的结果 -3. 如果向服务器传递4,3,2的同时要求做减法,则返回4-3-2的运算结果。 +3. 如果向服务器传递 4, 3, 2 的同时要求做减法,则返回 4-3-2 的运算结果。 -请自己实现一个程序来实现功能。 +请自己实现一个程序来实现以上功能。 我自己的实现: @@ -1269,7 +1492,7 @@ gcc My_op_server.c -o myserver ![](https://i.loli.net/2019/01/15/5c3d966b81c03.png) -其实主要是对程序的一点点小改动,只需要再客户端固定好发送的格式,服务端按照固定格式解析,然后返回结果即可。 +其实主要是对程序的一点点小改动,只需要在客户端固定好发送的格式,服务端按照固定格式解析,然后返回结果即可。 书上的实现: @@ -1285,7 +1508,7 @@ gcc op_client.c -o opclient gcc op_server.c -o opserver ``` -运行: +运行: ```shell ./opserver 9190 @@ -1300,9 +1523,9 @@ gcc op_server.c -o opserver #### 5.2.1 TCP 套接字中的 I/O 缓冲 -TCP 套接字的数据收发无边界。服务器即使调用 1 次 write 函数传输 40 字节的数据,客户端也有可能通过 4 次 read 函数调用每次读取 10 字节。但此处也有一些一问,服务器一次性传输了 40 字节,而客户端竟然可以缓慢的分批接受。客户端接受 10 字节后,剩下的 30 字节在何处等候呢? +TCP 套接字的数据收发无边界。服务器即使调用 1 次 write 函数传输 40 字节的数据,客户端也有可能通过 4 次 read 函数调用每次读取 10 字节。但此处也有一些疑问,服务器一次性传输了 40 字节,而客户端竟然可以缓慢的分批接收。客户端接收 10 字节后,剩下的 30 字节在何处等候呢? -实际上,write 函数调用后并非立即传输数据, read 函数调用后也并非马上接收数据。如图所示,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。 +实际上,write 函数调用后并非立即传输数据,read 函数调用后也并非马上接收数据。如图所示,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。 ![](https://i.loli.net/2019/01/16/5c3ea41cd93c6.png) @@ -1321,7 +1544,7 @@ I/O 缓冲特性可以整理如下: > - A:你好,最多可以向我传递 50 字节 > - B:好的 -> - A:我腾出了 20 字节的空间,最多可以接受 70 字节 +> - A:我腾出了 20 字节的空间,最多可以接收 70 字节 > - B:好的 数据收发也是如此,因此 TCP 中不会因为缓冲溢出而丢失数据。 @@ -1350,7 +1573,7 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 > [SYN] SEQ : 1000 , ACK:- -该消息中的 SEQ 为 1000 ,ACK 为空,而 SEQ 为1000 的含义如下: +该消息中的 SEQ 为 1000,ACK 为空,而 SEQ 为 1000 的含义如下: > 现在传递的数据包的序号为 1000,如果接收无误,请通知我向您传递 1001 号数据包。 @@ -1360,15 +1583,15 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 此时 SEQ 为 2000,ACK 为 1001,而 SEQ 为 2000 的含义如下: -> 现传递的数据包号为 2000 ,如果接受无误,请通知我向您传递 2001 号数据包。 +> 现在传递的数据包号为 2000,如果接收无误,请通知我向您传递 2001 号数据包。 而 ACK 1001 的含义如下: -> 刚才传输的 SEQ 为 1000 的数据包接受无误,现在请传递 SEQ 为 1001 的数据包。 +> 刚才传输的 SEQ 为 1000 的数据包接收无误,现在请传递 SEQ 为 1001 的数据包。 对于主机 A 首次传输的数据包的确认消息(ACK 1001)和为主机 B 传输数据做准备的同步消息(SEQ 2000)捆绑发送。因此,此种类消息又称为 SYN+ACK。 -收发数据前向数据包分配序号,并向对方通报此序号,这都是为了防止数据丢失做的准备。通过项数据包分配序号并确认,可以在数据包丢失时马上查看并重传丢失的数据包。因此 TCP 可以保证可靠的数据传输。 +收发数据前向数据包分配序号,并向对方通报此序号,这都是为了防止数据丢失做的准备。通过向数据包分配序号并确认,可以在数据包丢失时马上查看并重传丢失的数据包。因此 TCP 可以保证可靠的数据传输。 通过这三个过程,这样主机 A 和主机 B 就确认了彼此已经准备就绪。 @@ -1378,7 +1601,7 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 ![](https://i.loli.net/2019/01/16/5c3ed1a97ce2b.png) -图上给出了主机 A 分成 2 个数据包向主机 B 传输 200 字节的过程。首先,主机 A 通过 1 个数据包发送 100 个字节的数据,数据包的 SEQ 为 1200 。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。 +图上给出了主机 A 分成 2 个数据包向主机 B 传输 200 字节的过程。首先,主机 A 通过 1 个数据包发送 100 个字节的数据,数据包的 SEQ 为 1200。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。 此时的 ACK 号为 1301 而不是 1201,原因在于 ACK 号的增量为传输的数据字节数。假设每次 ACK 号不加传输的字节数,这样虽然可以确认数据包的传输,但无法明确 100 个字节全都正确传递还是丢失了一部分,比如只传递了 80 字节。因此按照如下公式传递 ACK 信息: @@ -1386,7 +1609,7 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 与三次握手协议相同,最后 + 1 是为了告知对方下次要传递的 SEQ 号。下面分析传输过程中数据包丢失的情况: -![](https://i.loli.net/2019/01/16/5c3ed371187a6.png)' +![](https://i.loli.net/2019/01/16/5c3ed371187a6.png) 上图表示了通过 SEQ 1301 数据包向主机 B 传递 100 字节数据。但中间发生了错误,主机 B 未收到,经过一段时间后,主机 A 仍然未收到对于 SEQ 1301 的 ACK 的确认,因此试着重传该数据包。为了完成该数据包的重传,TCP 套接字启动计时器以等待 ACK 应答。若相应计时器发生超时(Time-out!)则重传。 @@ -1403,7 +1626,7 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 ![](https://i.loli.net/2019/01/16/5c3ed7503c18c.png) -图中数据包内的 FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过过程经历 4 个阶段,因此又称四次握手(Four-way handshaking)。SEQ 和 ACK 的含义与之前讲解的内容一致,省略。图中,主机 A 传递了两次 ACK 5001,也许这里会有困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收了 ACK 消息后未接收到的数据重传的。 +图中数据包内的 FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过程经历 4 个阶段,因此又称四次握手(Four-way handshaking)。SEQ 和 ACK 的含义与之前讲解的内容一致,省略。图中,主机 A 传递了两次 ACK 5001,也许这里会有困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收了 ACK 消息后未接收到的数据重传的。 ### 5.3 基于 Windows 的实现 @@ -1415,11 +1638,13 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 1. **请说明 TCP 套接字连接设置的三次握手过程。尤其是 3 次数据交换过程每次收发的数据内容。** - 答:三次握手主要分为:①A通知B有数据,希望建立链接②B回复A可以建立连接③A回应了解,并开始建立连接。每次收发的数据内容主要有:①由主机1给主机2发送初始的SEQ:1000,ACK:- (为空),首次连接请求也成为SYN,表示收发数据前同步传输的消息。②主机2收到报文以后,给主机 1 传递信息,用一个新的SEQ:2000表示自己的序号,然后ACK:1001代表已经接受到主机1的消息,希望接受下一个消息。这种类型的消息又称为SYN+ACK③主机1收到主机2的确认以后,还需要给主机2给出确认,此时再发送一次SEQ:1001和ACK:2001。 + 答:TCP 套接字的生命周期主要可分为 3 个部分:①与对方套接字建立连接 ②与对方套接字进行数据交换 ③断开与对方套接字的连接。 -2. **TCP 是可靠的数据传输协议,但在通过网络通信的过程中可能丢失数据。请通过 ACK 和 SEQ 说明 TCP 通过和何种机制保证丢失数据的可靠传输。** + 其中,在第一步建立连接的阶段,又可细分为 3 个步骤(即`三次握手`):①由主机 1 给主机 2 发送初始的 SEQ,首次连接请求的关键字是 SYN,表示收发数据前同步传输的消息,此时报文的 ACK 一般为空。②主机 2 收到报文以后,给主机 1 传递信息,用一个新的 SEQ 表示自己的序号,然后 ACK 代表已经接收到主机 1 的消息,希望接收下一个消息。③主机 1 收到主机 2 的确认以后,还需要给主机 2 给出确认,此时再发送一次 SEQ 和 ACK。 - 答:通过超时重传机制来保证,如果报文发出去的特定时间内,发送消息的主机没有收到另一个主机的回复,那么就继续发送这条消息,直到收到回复为止。 +2. **TCP 是可靠的数据传输协议,但在通过网络通信的过程中可能丢失数据。请通过 ACK 和 SEQ 说明 TCP 通过何种机制保证丢失数据的可靠传输。** + + 答:TCP 通过超时重传机制和确认应答(ACK)机制来保证可靠传输。具体过程如下:发送方每次发送数据时都会带上一个序列号(SEQ),接收方收到数据后会返回一个确认号(ACK),ACK 号的值等于 SEQ 号加上接收到的字节数再加 1,表示期待接收的下一个序列号。如果发送方在规定时间内没有收到对应的 ACK 确认,TCP 套接字的计时器会发生超时,发送方会重传该数据包。通过这种 SEQ/ACK 机制配合超时重传,TCP 可以确保数据包的可靠传输。 3. **TCP 套接字中调用 write 和 read 函数时数据如何移动?结合 I/O 缓冲进行说明。** @@ -1427,8 +1652,7 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 4. **对方主机的输入缓冲剩余 50 字节空间时,若本主机通过 write 函数请求传输 70 字节,请问 TCP 如何处理这种情况?** - 答:TCP 中有滑动窗口控制协议,所以传输的时候会保证传输的字节数小于等于自己能接受的字节数。 - + 答:TCP 中有滑动窗口控制协议,所以传输的时候会保证传输的字节数小于等于对方能接收的字节数。在这种情况下,TCP 只会发送 50 字节的数据(或者更少),剩余的 20 字节会保留在发送方的输出缓冲中,等待对方腾出更多空间后再发送。write 函数可能会阻塞等待,或者返回实际发送的字节数(部分写入)。 ## 第 6 章 基于 UDP 的服务端/客户端 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -1439,7 +1663,7 @@ TCP 是内容较多的一个协议,而本章中的 UDP 内容较少,但是 #### 6.1.1 UDP 套接字的特点 -通过寄信来说明 UDP 的工作原理,这是讲解 UDP 时使用的传统示例,它与 UDP 的特点完全相同。寄信前应现在信封上填好寄信人和收信人的地址,之后贴上邮票放进邮筒即可。当然,信件的特点使我们无法确认信件是否被收到。邮寄过程中也可能发生信件丢失的情况。也就是说,信件是一种不可靠的传输方式,UDP 也是一种不可靠的数据传输方式。 +通过寄信来说明 UDP 的工作原理,这是讲解 UDP 时使用的传统示例,它与 UDP 的特点完全相同。寄信前应先在信封上填好寄信人和收信人的地址,之后贴上邮票放进邮筒即可。当然,信件的特点使我们无法确认信件是否被收到。邮寄过程中也可能发生信件丢失的情况。也就是说,信件是一种不可靠的传输方式,UDP 也是一种不可靠的数据传输方式。 因为 UDP 没有 TCP 那么复杂,所以编程难度比较小,性能也比 TCP 高。在更重视性能的情况下可以选择 UDP 的传输方式。 @@ -1457,7 +1681,7 @@ TCP 与 UDP 的区别很大一部分来源于流控制。也就是说 TCP 的生 UDP 也具有一定的可靠性。对于通过网络实时传递的视频或者音频时情况有所不同。对于多媒体数据而言,丢失一部分数据也没有太大问题,这只是会暂时引起画面抖动,或者出现细微的杂音。但是要提供实时服务,速度就成为了一个很重要的因素。因此流控制就显得有一点多余,这时就要考虑使用 UDP 。TCP 比 UDP 慢的原因主要有以下两点: -- 收发数据前后进行的连接设置及清楚过程。 +- 收发数据前后进行的连接设置及清除过程。 - 收发过程中为保证可靠性而添加的流控制。 如果收发的数据量小但是需要频繁连接时,UDP 比 TCP 更高效。 @@ -1470,7 +1694,7 @@ UDP 中的服务端和客户端不像 TCP 那样在连接状态下交换数据 #### 6.2.2 UDP 服务器和客户端均只需一个套接字 -TCP 中,套接字之间应该是一对一的关系。若要向 10 个客户端提供服务,除了守门的服务器套接字之外,还需要 10 个服务器套接字。但在 UDP 中,不管事服务器端还是客户端都只需要 1 个套接字。只需要一个 UDP 套接字就可以向任意主机传输数据,如图所示: +TCP 中,套接字之间应该是一对一的关系。若要向 10 个客户端提供服务,除了守门的服务器套接字之外,还需要 10 个服务器套接字。但在 UDP 中,不管是服务器端还是客户端都只需要 1 个套接字。只需要一个 UDP 套接字就可以向任意主机传输数据,如图所示: ![](https://i.loli.net/2019/01/17/5c3fd703f3c40.png) @@ -1485,7 +1709,7 @@ TCP 中,套接字之间应该是一对一的关系。若要向 10 个客户端 ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); /* -成功时返回传输的字节数,失败是返回 -1 +成功时返回发送的字节数,失败时返回 -1 sock: 用于传输数据的 UDP 套接字 buff: 保存待传输数据的缓冲地址值 nbytes: 待传输的数据长度,以字节为单位 @@ -1502,10 +1726,10 @@ addrlen: 传递给参数 to 的地址值结构体变量长度 ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen); /* -成功时返回传输的字节数,失败是返回 -1 -sock: 用于接收数据的UDP套接字文件描述符 -buff: 保存接收数据的缓冲地址值 -nbytes: 可接收的最大字节数,故无法超过参数buff所指的缓冲大小 +成功时返回接收的字节数,失败时返回 -1 +sock: 用于传输数据的 UDP 套接字 +buff: 保存待传输数据的缓冲地址值 +nbytes: 待传输的数据长度,以字节为单位 flags: 可选项参数,若没有则传递 0 from: 存有发送端地址信息的 sockaddr 结构体变量的地址值 addrlen: 保存参数 from 的结构体变量长度的变量地址值。 @@ -1540,7 +1764,7 @@ TCP 客户端套接字在调用 connect 函数时自动分配IP地址和端口 #### 6.2.5 UDP 客户端套接字的地址分配 -仔细观察 UDP 客户端可以发现,UDP 客户端缺少了把IP和端口分配给套接字的过程。TCP 客户端调用 connect 函数自动完成此过程,而 UDP 中连接能承担相同功能的函数调用语句都没有。究竟在什么时候分配IP和端口号呢? +仔细观察 UDP 客户端可以发现,UDP 客户端缺少了把IP和端口分配给套接字的过程。TCP 客户端调用 connect 函数自动完成此过程,而 UDP 中连能承担相同功能的函数调用语句都没有。究竟在什么时候分配IP和端口号呢? UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的地址分配工作,因此调用 bind 函数。当然,bind 函数在 TCP 程序中出现过,但 bind 函数不区分 TCP 和 UDP,也就是说,在 UDP 程序中同样可以调用。另外,如果调用 sendto 函数尚未分配地址信息,则在首次调用 sendto 函数时给相应套接字自动分配 IP 和端口。而且此时分配的地址一直保留到程序结束为止,因此也可以用来和其他 UDP 套接字进行数据交换。当然,IP 用主机IP,端口号用未选用的任意端口号。 @@ -1552,7 +1776,7 @@ UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的 前面说得 TCP 数据传输中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义」 -相反,UDP 是具有数据边界的协议,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数完全一致,这样才能保证接收全部已经发送的数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。通过一个例子来进行验证: +相反,UDP 是具有数据边界的协议,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数应该完全一致,这样才能保证接收全部已经发送的数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。通过一个例子来进行验证: - [bound_host1.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch06/bound_host1.c) - [bound_host2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch06/bound_host2.c) @@ -1570,7 +1794,7 @@ gcc bound_host2.c -o host2 ![](https://i.loli.net/2019/01/17/5c3ff966a8d34.png) -host1 是服务端,host2 是客户端,host2 一次性把数据发给服务端后,结束程序。此时数据已经在服务端了。UDP I/O 调用次数必须一致,所以服务器端也需要调用三次。但是因为服务端每隔五秒才接收一次,所以服务端每隔五秒输出一条消息。 +host1 是服务端,host2 是客户端,host2 一次性把数据发给服务端后,结束程序。但是因为服务端每隔五秒才接收一次,所以服务端每隔五秒接收一次消息。 **从运行结果也可以证明 UDP 通信过程中 I/O 的调用次数必须保持一致** @@ -1617,9 +1841,9 @@ connect(sock, (struct sockaddr *)&adr, sizeof(adr)); > 以下答案仅代表本人个人观点,可能不是正确答案。 -1. **UDP 为什么比 TCP 快?为什么 TCP 传输可靠而 TCP 传输不可靠?** +1. **UDP 为什么比 TCP 快?为什么 TCP 传输可靠而 UDP 传输不可靠?** - 答:为了提供可靠的数据传输服务,TCP 在不可靠的IP层进行流控制,而 UDP 缺少这种流控制。所以 UDP 是不可靠的连接。 + 答:为了提供可靠的数据传输服务,TCP 在不可靠的IP层进行流控制,而 UDP 缺少这种流控制。所以 UDP 是不可靠的传输方式。 2. **下面不属于 UDP 特点的是?** @@ -1631,6 +1855,8 @@ connect(sock, (struct sockaddr *)&adr, sizeof(adr)); 4. **UDP 套接字和 TCP 套接字可以共存。若需要,可以同时在同一主机进行 TCP 和 UDP 数据传输。** 5. 针对 UDP 函数也可以调用 connect 函数,此时 UDP 套接字跟 TCP 套接字相同,也需要经过 3 次握手阶段。 + 答:第2句和第5句不属于 UDP 的特点(即这两句话是错误的)。第2句错误是因为 UDP 只需一个套接字就可以向多个目标传输数据;第5句错误是因为 UDP 调用 connect 函数只是注册目标地址信息,不会进行 TCP 那样的三次握手过程。 + 3. **UDP 数据报向对方主机的 UDP 套接字传递过程中,IP 和 UDP 分别负责哪些部分?** 答:IP的作用就是让离开主机的 UDP 数据包准确传递到另一个主机。但把 UDP 包最终交给主机的某一 UDP 套接字的过程则是由 UDP 完成的。UDP 的最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。 @@ -1646,7 +1872,6 @@ connect(sock, (struct sockaddr *)&adr, sizeof(adr)); 6. **TCP 客户端必须调用 connect 函数,而 UDP 可以选择性调用。请问,在 UDP 中调用 connect 函数有哪些好处?** 答:要与同一个主机进行长时间通信时,将 UDP 套接字变成已连接套接字会提高效率。因为三个阶段中,第一个阶段和第三个阶段占用了一大部分时间,调用 connect 函数可以节省这些时间。 - ## 第 7 章 优雅的断开套接字的连接 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -1659,7 +1884,7 @@ TCP 的断开连接过程比建立连接更重要,因为连接过程中一般 #### 7.1.1 单方面断开连接带来的问题 -Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。如图所示: +Linux 的 close 函数和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。如图所示: ![](https://i.loli.net/2019/01/18/5c412a8baa2d8.png) @@ -1675,7 +1900,7 @@ Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断 ![](https://i.loli.net/2019/01/18/5c412c3ba25dd.png) -一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。 +一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 的 close 函数和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。 #### 7.1.3 针对优雅断开的 shutdown 函数 @@ -1697,7 +1922,7 @@ howto: 传递断开方式信息 - `SHUT_WR` : 断开输出流 - `SHUT_RDWR` : 同时断开 I/O 流 -若向 shutdown 的第二个参数传递`SHUT_RD`,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也回抹去,而且无法调用相关函数。如果向 shutdown 的第二个参数传递`SHUT_WR`,则中断输出流,也就无法传输数据。若如果输出缓冲中还有未传输的数据,则将传递给目标主机。最后,若传递关键字`SHUT_RDWR`,则同时中断 I/O 流。这相当于分 2 次调用 shutdown ,其中一次以`SHUT_RD`为参数,另一次以`SHUT_WR`为参数。 +若向 shutdown 的第二个参数传递`SHUT_RD`,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也会抹去,而且无法调用相关函数。如果向 shutdown 的第二个参数传递`SHUT_WR`,则中断输出流,也就无法传输数据。若如果输出缓冲中还有未传输的数据,则将传递给目标主机。最后,若传递关键字`SHUT_RDWR`,则同时中断 I/O 流。这相当于分 2 次调用 shutdown ,其中一次以`SHUT_RD`为参数,另一次以`SHUT_WR`为参数。 #### 7.1.4 为何要半关闭 @@ -1705,7 +1930,7 @@ howto: 传递断开方式信息 > 一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。 -此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的实现难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序**阻塞**。 +此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的编写难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序**阻塞**。 > 是否可以让服务器和客户端约定一个代表文件尾的字符? @@ -1743,7 +1968,25 @@ gcc file_server.c -o fserver ### 7.2 基于 Windows 的实现 -暂略 +Windows 平台下的半关闭实现与 Linux 类似,同样使用 shutdown 函数。函数原型如下: + +```c +#include +int shutdown(SOCKET s, int how); +/* +成功时返回 0,失败时返回 SOCKET_ERROR +s: 需要断开的套接字句柄 +how: 传递断开方式信息 +*/ +``` + +Windows 下的 how 参数取值与 Linux 略有不同: + +- `SD_RECEIVE` : 断开输入流(相当于 Linux 的 SHUT_RD) +- `SD_SEND` : 断开输出流(相当于 Linux 的 SHUT_WR) +- `SD_BOTH` : 同时断开 I/O 流(相当于 Linux 的 SHUT_RDWR) + +使用方法与 Linux 版本基本一致,只需注意参数名称的差异即可。 ### 7.3 习题 @@ -1755,12 +1998,17 @@ gcc file_server.c -o fserver 2. **Linux 中的 close 函数或 Windows 中的 closesocket 函数属于单方面断开连接的方法,有可能带来一些问题。什么是单方面断开连接?什么情形下会出现问题?** - 答:单方面断开连接就是两台主机正在通信,其中一台主机关闭了所有连接,那么一台主机向另一台主机传输的数据可能会没有接收到而损毁。传输文件的服务器只需连续传输文件数据即可,而客户端不知道需要接收数据到何时。客户端也没有办法无休止的调用输入函数。现在需要一个 EOF 代表数据已经传输完毕,那么这时就需要半关闭,服务端把自己的输出流关了,这时客户端就知数据已经传输完毕,因为服务端的输入流还没关,客户端可以给服务器汇报,接收完毕。 + 答:单方面断开连接是指通信的一方调用 close(Linux)或 closesocket(Windows)函数,同时关闭输入流和输出流。这会导致该主机既不能发送数据也不能接收数据。 + + 问题出现的典型情形是:服务器向客户端传输文件,传输完成后需要接收客户端的确认信息(如「Thank you」)。如果服务器调用 close 函数发送 EOF,虽然客户端能知道文件传输完毕,但服务器也无法再接收客户端发送的确认信息了。解决方法是使用 shutdown 函数进行半关闭,只关闭输出流,保留输入流。 3. **什么是半关闭?针对输出流执行半关闭的主机处于何种状态?半关闭会导致对方主机接收什么消息?** - 答:半关闭就是把输入流或者输出流关了。针对输出流执行半关闭的主机处于可以接收数据而不能发送数据。半关闭会导致对方主机接收一个 EOF 文件结束符。对方就知道你的数据已经传输完毕。 + 答:半关闭是指只关闭套接字的输入流或输出流中的一种,而不是同时关闭两者。 + 针对输出流执行半关闭(即调用 `shutdown(sock, SHUT_WR)`)的主机处于:可以接收数据,但无法发送数据的状态。 + + 半关闭会导致对方主机的接收函数返回 EOF(文件结束符),对方主机由此得知数据已传输完毕。 ## 第 8 章 域名及网络地址 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -1812,13 +2060,13 @@ struct hostent }; ``` -从上述结构体可以看出,不止返回IP信息,同时还带着其他信息一起返回。域名转换成IP时只需要关注 h_addr_list 。下面简要说明上述结构体的成员: +从上述结构体可以看出,不止返回IP信息,同时还带着其他信息一起返回。域名转换成IP时只需要关注 h_addr_list。下面简要说明上述结构体的成员: - h_name:该变量中存有官方域名(Official domain name)。官方域名代表某一主页,但实际上,一些著名公司的域名并没有用官方域名注册。 - h_aliases:可以通过多个域名访问同一主页。同一IP可以绑定多个域名,因此,除官方域名外还可以指定其他域名。这些信息可以通过 h_aliases 获得。 -- h_addrtype:gethostbyname 函数不仅支持 IPV4 还支持 IPV6 。因此可以通过此变量获取保存在 h_addr_list 的IP地址族信息。若是 IPV4 ,则此变量中存有 AF_INET。 -- h_length:保存IP地址长度。若是 IPV4 地址,因为是 4 个字节,则保存4;IPV6 时,因为是 16 个字节,故保存 16 -- h_addr_list:这个是最重要的的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡,。此时可以通过此变量获取IP地址信息。 +- h_addrtype:gethostbyname 函数不仅支持 IPv4 还支持 IPv6。因此可以通过此变量获取保存在 h_addr_list 的IP地址族信息。若是 IPv4,则此变量中存有 AF_INET。 +- h_length:保存IP地址长度。若是 IPv4 地址,因为是 4 个字节,则保存 4;IPv6 时,因为是 16 个字节,故保存 16。 +- h_addr_list:这个是最重要的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡。此时可以通过此变量获取IP地址信息。 调用 gethostbyname 函数后,返回的结构体变量如图所示: @@ -1859,15 +2107,10 @@ gcc gethostbyname.c -o hostname inet_ntoa(*(struct in_addr *)host->h_addr_list[i]) ``` -若只看 hostent 的定义,结构体成员 h_addr_list 指向字符串指针数组(由多个字符串地址构成的数组)。但是字符串指针数组保存的元素实际指向的是 in_addr 结构体变量的地址值而非字符串,也就是说`(struct in_addr *)host->h_addr_list[i]`其实是一个指针,然后用`*`符号取具体的值。如图所示: +若只看 hostent 的定义,结构体成员 h_addr_list 指向字符串指针数组(由多个字符串地址构成的数组)。但是字符串指针数组保存的元素实际指向的是 in_addr 结构体变量中地址值而非字符串,也就是说`(struct in_addr *)host->h_addr_list[i]`其实是一个指针,然后用`*`符号取具体的值。如图所示: ![](https://i.loli.net/2019/01/18/5c419658a73b8.png) ->**为什么是「cha\*」而不是「in_addr\*」** - 「hostent」结构体的成员「h_addr_st」指向的数组类型并不是「in_addr」结构体的指针数组,而是用了「char」指针。「hostent」结构体并非只为IPV4准备。「h_addr_list」指向的数组中也可以保存Pv6地址信息。考虑到通用性,声明为「char」指针类型的数组 ->**声明为「void」指针类型是否更合理?** - 当然如此。指针对象不明确时,更适合使用vod指针类型。此处不用是版本问题 - #### 8.2.3 利用IP地址获取域名 请看下面的函数定义: @@ -1877,9 +2120,9 @@ inet_ntoa(*(struct in_addr *)host->h_addr_list[i]) struct hostent *gethostbyaddr(const char *addr, socklen_t len, int family); /* 成功时返回 hostent 结构体变量地址值,失败时返回 NULL 指针 -addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPV4 地址之外的全部信息,该变量的类型声明为 char 指针 -len: 向第一个参数传递的地址信息的字节数,IPV4时为 4 ,IPV6 时为16. -family: 传递地址族信息,ipv4 是 AF_INET ,IPV6是 AF_INET6 +addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPv4 地址之外的全部信息,该变量的类型声明为 char 指针。 +len: 向第一个参数传递的地址信息的字节数,IPv4 时为 4,IPv6 时为 16。 +family: 传递地址族信息,IPv4 是 AF_INET,IPv6 是 AF_INET6。 */ ``` @@ -1908,25 +2151,38 @@ gcc gethostbyaddr.c -o hostaddr > 以下答案仅代表本人个人观点,可能不是正确答案。 -1. **下列关于DNS的说法错误的是?** +1. **下列关于 DNS 的说法错误的是?** - 答:字体加粗的表示正确答案。 + 答:说法错误的是选项 2 和 4。 - 1. **因为DNS存在,故可以使用域名代替IP** - 2. DNS服务器实际上是路由器,因为路由器根据域名决定数据的路径 - 3. **所有域名信息并非集中与 1 台 DNS 服务器,但可以获取某一 DNS 服务器中未注册的所有地址** - 4. DNS 服务器根据操作系统进行区分,Windows 下的 DNS 服务器和 Linux 下的 DNS 服务器是不同的。 + 1. 因为 DNS 的存在,故可以使用域名代替 IP。(正确) + 2. DNS 服务器实际上是路由器,因为路由器根据域名决定数据的路径。(**错误**:DNS 服务器不是路由器,路由器是根据 IP 地址而非域名来决定数据转发路径的) + 3. 所有域名信息并非集中于 1 台 DNS 服务器,但可以获取某一 DNS 服务器中未注册的所有地址。(正确:通过 DNS 的层次化结构,可以逐级查询获取未在本地 DNS 服务器中注册的地址) + 4. DNS 服务器根据操作系统进行区分,Windows 下的 DNS 服务器和 Linux 下的 DNS 服务器是不同的。(**错误**:DNS 协议是标准化的,与操作系统无关) 2. **阅读如下对话,并说明东秀的方案是否可行。(因为对话的字太多,用图代替)** ![](https://i.loli.net/2019/01/18/5c41a22f35390.png) - 答:答案就是可行,DNS 服务器是分布式的,一台坏了可以找其他的。 + 答:东秀的方案是可行的。DNS 服务器采用分布式层次结构,具有冗余性和容错性。当一台 DNS 服务器故障时,可以自动切换到其他可用的 DNS 服务器进行查询,不会导致整个域名解析系统瘫痪。此外,DNS 解析结果通常会在本地缓存一段时间,即使 DNS 服务器暂时不可用,已缓存的解析记录仍然可以正常使用。 -3. **再浏览器地址输入 www.orentec.co.kr ,并整理出主页显示过程。假设浏览器访问默认 DNS 服务器中并没有关于 www.orentec.co.kr 的地址信息.** +3. **在浏览器地址输入 www.orentec.co.kr,并整理出主页显示过程。假设浏览器访问默认 DNS 服务器中并没有关于 www.orentec.co.kr 的地址信息。** - 答:可以参考一下知乎回答,[在浏览器地址栏输入一个URL后回车,背后会进行哪些技术步骤?](https://www.zhihu.com/question/34873227/answer/518086565),我用我自己的理解,简单说一下,首先会去向上一级的 DNS 服务器去查询,通过这种方式逐级向上传递信息,一直到达根服务器时,它知道应该向哪个 DNS 服务器发起询问。向下传递解析请求,得到IP地址候原路返回,最后会将解析的IP地址传递到发起请求的主机。 + 答:完整的域名解析过程如下: + 1. **本地缓存查询**:浏览器首先检查本地缓存和操作系统的 hosts 文件,如果找到对应 IP 地址则直接使用。 + 2. **向本地 DNS 服务器发起查询**:如果没有找到缓存,浏览器向系统配置的本地 DNS 服务器发起递归查询请求。 + 3. **本地 DNS 服务器迭代查询**: + - 本地 DNS 服务器首先检查自己的缓存,若没有则向根域名服务器(Root Server,".")发起查询。 + - 根域名服务器返回负责 .kr 域的顶级域名服务器(TLD Server)地址。 + - 本地 DNS 服务器向 .kr 的顶级域名服务器发起查询。 + - 顶级域名服务器返回负责 orentec.co.kr 的权威域名服务器地址。 + - 本地 DNS 服务器向权威域名服务器发起查询。 + - 权威域名服务器返回 www.orentec.co.kr 的 IP 地址。 + 4. **返回结果**:本地 DNS 服务器将解析到的 IP 地址返回给浏览器,同时在本地缓存该结果。 + 5. **建立连接**:浏览器使用获得的 IP 地址与目标服务器建立 TCP 连接,发送 HTTP 请求,获取并渲染网页内容。 + + 可以参考知乎回答:[在浏览器地址栏输入一个 URL 后回车,背后会进行哪些技术步骤?](https://www.zhihu.com/question/34873227/answer/518086565) ## 第 9 章 套接字的多种可选项 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -1939,25 +2195,25 @@ gcc gethostbyaddr.c -o hostaddr 我们之前写得程序都是创建好套接字之后直接使用的,此时通过默认的套接字特性进行数据通信,这里列出了一些套接字可选项。 -| 协议层 | 选项名 | 读取 | 设置 | -| :---------: | :---------------: | :---: | :---: | -| SOL_SOCKET | SO_SNDBUF | O | O | -| SOL_SOCKET | SO_RCVBUF | O | O | -| SOL_SOCKET | SO_REUSEADDR | O | O | -| SOL_SOCKET | SO_KEEPALIVE | O | O | -| SOL_SOCKET | SO_BROADCAST | O | O | -| SOL_SOCKET | SO_DONTROUTE | O | O | -| SOL_SOCKET | SO_OOBINLINE | O | O | -| SOL_SOCKET | SO_ERROR | O | X | -| SOL_SOCKET | SO_TYPE | O | X | -| IPPROTO_IP | IP_TOS | O | O | -| IPPROTO_IP | IP_TTL | O | O | -| IPPROTO_IP | IP_MULTICAST_TTL | O | O | -| IPPROTO_IP | IP_MULTICAST_LOOP | O | O | -| IPPROTO_IP | IP_MULTICAST_IF | O | O | -| IPPROTO_TCP | TCP_KEEPALIVE | O | O | -| IPPROTO_TCP | TCP_NODELAY | O | O | -| IPPROTO_TCP | TCP_MAXSEG | O | O | +| 协议层 | 选项名 | 读取 | 设置 | +| :----: | :----: |:--: | :--: | +| SOL_SOCKET | SO_SNDBUF | O | O | +| SOL_SOCKET | SO_RCVBUF | O | O | +| SOL_SOCKET | SO_REUSEADDR | O | O | +| SOL_SOCKET | SO_KEEPALIVE | O | O | +| SOL_SOCKET | SO_BROADCAST | O | O | +| SOL_SOCKET | SO_DONTROUTE | O | O | +| SOL_SOCKET | SO_OOBINLINE | O | O | +| SOL_SOCKET | SO_ERROR | O | X | +| SOL_SOCKET | SO_TYPE | O | X | +| IPPROTO_IP | IP_TOS | O | O | +| IPPROTO_IP | IP_TTL | O | O | +| IPPROTO_IP | IP_MULTICAST_TTL | O | O | +| IPPROTO_IP | IP_MULTICAST_LOOP | O | O | +| IPPROTO_IP | IP_MULTICAST_IF | O | O | +| IPPROTO_TCP | TCP_KEEPALIVE | O | O | +| IPPROTO_TCP | TCP_NODELAY | O | O | +| IPPROTO_TCP | TCP_MAXSEG | O | O | 从表中可以看出,套接字可选项是分层的。 @@ -1981,11 +2237,11 @@ sock: 用于查看选项套接字文件描述符 level: 要查看的可选项协议层 optname: 要查看的可选项名 optval: 保存查看结果的缓冲地址值 -optlen: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信息的字节数。 +optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。 */ ``` -上述函数可以用来读取套接字可选项,下面的函数可以更改可选项:d +上述函数可以用来读取套接字可选项,下面的函数可以更改可选项: ```c #include @@ -1996,8 +2252,8 @@ int setsockopt(int sock, int level, int optname, const void *optval, socklen_t o sock: 用于更改选项套接字文件描述符 level: 要更改的可选项协议层 optname: 要更改的可选项名 -optval: 保存要更改的选项信息的缓冲地址值 -optlen: 向第四个参数传递的可选信息的字节数。 +optval: 保存更改结果的缓冲地址值 +optlen: 向第四个参数传递的缓冲大小值(选项值的长度)。 */ ``` @@ -2023,7 +2279,7 @@ Socket type two: 2 首先创建了一个 TCP 套接字和一个 UDP 套接字。然后通过调用 getsockopt 函数来获得当前套接字的状态。 -验证套接类型的 SO_TYPE 是只读可选项,因为**套接字类型只能在创建时决定,以后不能再更改**。 +用于验证套接类型的 SO_TYPE 是只读可选项,因为**套接字类型只能在创建时决定,以后不能再更改**。 #### 9.1.3 `SO_SNDBUF` & `SO_RCVBUF` @@ -2056,7 +2312,7 @@ Output buffer size: 16384 编译运行: ```shell -gcc get_buf.c -o setbuf +gcc set_buf.c -o setbuf ./setbuf ``` @@ -2093,19 +2349,19 @@ Output buffer size: 6144 假设图中主机 A 是服务器,因为是主机 A 向 B 发送 FIN 消息,故可想象成服务器端在控制台中输入 CTRL+C 。但是问题是,套接字经过四次握手后并没有立即消除,而是要经过一段时间的 Time-wait 状态。当然,只有先断开连接的(先发送 FIN 消息的)主机才经过 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait 过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中会发生错误。 -**实际上,不论是服务端还是客户端,都要经过一段时间的 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意制定的,所以无需过多关注 Time-wait 状态。** +**实际上,不论是服务端还是客户端,都要经过一段时间的 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意指定的,所以无需过多关注 Time-wait 状态。** -那到底为什么会有 Time-wait 状态呢,在图中假设,主机 A 向主机 B 传输 ACK 消息(SEQ 5001 , ACK 7502 )后立刻消除套接字。但是最后这条 ACK 消息在传递过程中丢失,没有传递主机 B ,这时主机 B 就会试图重传。但是此时主机 A 已经是完全终止状态,因为主机 B 永远无法收到从主机 A 最后传来的 ACK 消息。基于这些问题的考虑,所以要设计 Time-wait 状态。 +那到底为什么会有 Time-wait 状态呢,在图中假设,主机 A 向主机 B 传输 ACK 消息(SEQ 5001 , ACK 7502 )后立刻消除套接字。但是最后这条 ACK 消息在传递过程中丢失,没有传递主机 B ,这时主机 B 就会试图重传。但是此时主机 A 已经是完全终止状态,因此主机 B 永远无法收到从主机 A 最后传来的 ACK 消息。基于这些问题的考虑,所以要设计 Time-wait 状态。 #### 9.2.3 地址再分配 -Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。 +Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务器以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。 ![](https://i.loli.net/2019/01/19/5c42dec2ba42b.png) 从图上可以看出,在主机 A 四次握手的过程中,如果最后的数据丢失,则主机 B 会认为主机 A 未能收到自己发送的 FIN 信息,因此重传。这时,收到的 FIN 消息的主机 A 将重启 Time-wait 计时器。因此,如果网络状况不理想, Time-wait 将持续。 -解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。适当调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR 的默认值为 0.这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 [reuseadr_eserver.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch09/reuseadr_eserver.c) 给出,只需要把注释掉的东西接解除注释即可。 +解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。适当调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR 的默认值为 0.这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 [reuseadr_eserver.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch09/reuseadr_eserver.c) 给出,只需要把注释掉的东西解除注释即可。 ```c optlen = sizeof(option); @@ -2146,14 +2402,13 @@ setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, sizeof(opt_val)); ```c opt_len = sizeof(opt_val); -getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len); +getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, &opt_len); ``` 如果正在使用`Nagle` 算法,那么 opt_val 值为 0,如果禁用则为 1. 关于这个算法,可以参考这个回答:[TCP连接中启用和禁用TCP_NODELAY有什么影响?](https://www.zhihu.com/question/42308970/answer/246334766) - ### 9.4 基于 Windows 的实现 暂略 @@ -2164,17 +2419,16 @@ getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len); 1. **下列关于 Time-wait 状态的说法错误的是?** - 答:以下字体加粗的代表正确。 + 答:错误的说法是第 1、3、4 项。正确的说法是第 2 项(加粗显示)。 - 1. Time-wait 状态只在服务器的套接字中发生 - 2. **断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。** - 3. Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关 - 4. Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生 + 1. ~~Time-wait 状态只在服务器的套接字中发生~~(错误:客户端先断开连接时也会进入 Time-wait 状态) + 2. **断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。**(正确) + 3. ~~Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关~~(错误:Time-wait 状态与断开连接的四次握手过程直接相关) + 4. ~~Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生~~(错误:Time-wait 状态对于保证 TCP 连接可靠关闭是必要的,但在某些紧急重启场景下可通过 SO_REUSEADDR 重用端口) 2. **TCP_NODELAY 可选项与 Nagle 算法有关,可通过它禁用 Nagle 算法。请问何时应考虑禁用 Nagle 算法?结合收发数据的特性给出说明。** 答:当网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快,比如说在传输大文件时。 - ## 第 10 章 多进程服务器端 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -2189,7 +2443,7 @@ getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len); - 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务 - 多线程服务器:通过生成与客户端等量的线程提供服务 -先是第一种方法:多进程服务器 +先介绍第一种方法:多进程服务器。 #### 10.1.2 理解进程 @@ -2197,31 +2451,31 @@ getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, opt_len); > 占用内存空间的正在运行的程序 -假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这是才可称为进程。 +假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这时才可称为进程。 -#### 10.1.3 进程 ID +#### 10.1.3 进程 ID -在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程ID」,其值为大于 2 的证书。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1 。接下来观察在 Linux 中运行的进程。 +在介绍进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程 ID」,其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1。接下来观察在 Linux 中运行的进程。 ```shell ps au ``` -通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。 +通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程 ID)。参数 a 和 u 列出了所有进程的详细信息。 ![](https://i.loli.net/2019/01/20/5c43d7c1f2a8b.png) #### 10.1.4 通过调用 fork 函数创建进程 -创建进程的方式很多,此处只介绍用于创建多进程服务端的 fork 函数。 +创建进程的方式很多,此处只介绍用于创建多进程服务器的 fork 函数。 ```c #include pid_t fork(void); -// 成功时返回进程ID,失败时返回 -1 +// 成功时返回进程ID,失败时返回 -1 ``` -fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确的说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。 +fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确地说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。 - 父进程:fork 函数返回子进程 ID - 子进程:fork 函数返回 0 @@ -2230,7 +2484,7 @@ fork 函数将创建调用的进程副本。也就是说,并非根据完全不 ![](https://i.loli.net/2019/01/20/5c43da5412b90.png) -从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程的 lval 的值增加 1 ,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval 。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子: +从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回值区分父子进程。父进程的 lval 的值增加 1,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子: - [fork.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch10/fork.c) @@ -2267,7 +2521,7 @@ gcc fork.c -o fork ![](https://i.loli.net/2019/01/20/5c43e054e7f6f.png) -可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码,对于父进程来说,fork 函数返回的是子进程的ID,对于子进程来说,fork 函数返回 0。所以这两个变量,父进程进行了 +2 操作 ,而子进程进行了 -2 操作,所以结果是这样。 +可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码。对于父进程来说,fork 函数返回的是子进程的 ID,对于子进程来说,fork 函数返回 0。在 fork 之后,父进程对两个变量进行了 -2 操作,而子进程对两个变量进行了 +2 操作,所以结果是这样。 ### 10.2 进程和僵尸进程 @@ -2300,9 +2554,9 @@ gcc fork.c -o fork - 传递参数并调用 exit() 函数 - main 函数中执行 return 语句并返回值 -**向 exit 函数传递的参数值和 main 函数的 return 语句返回的值都回传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。**也就是说将子进程变成僵尸进程的正是操作系统。既然如此,僵尸进程何时被销毁呢? +**向 exit 函数传递的参数值和 main 函数的 return 语句返回的值都会传递给操作系统。而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。**也就是说将子进程变成僵尸进程的正是操作系统。既然如此,僵尸进程何时被销毁呢? -> 应该向创建子进程册父进程传递子进程的 exit 参数值或 return 语句的返回值。 +> 应该向创建子进程的父进程传递子进程的 exit 参数值或 return 语句的返回值。 如何向父进程传递这些值呢?操作系统不会主动把这些值传递给父进程。只有父进程主动发起请求(函数调用)的时候,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。也就是说,父母要负责收回自己生的孩子。接下来的示例是创建僵尸进程: @@ -2324,7 +2578,7 @@ int main(int argc, char *argv[]) sleep(30); } if (pid == 0) - puts("End child proess"); + puts("End child process"); else puts("End parent process"); return 0; @@ -2470,7 +2724,7 @@ int main(int argc, char *argv[]) } else { - //调用waitpid 传递参数 WNOHANG ,这样之前存在没有终止的子进程则返回0 + // 调用 waitpid 传递参数 WNOHANG,这样如果没有终止的子进程则返回 0 while (!waitpid(-1, &status, WNOHANG)) { sleep(3); @@ -2504,7 +2758,7 @@ gcc waitpid.c -o waitpid #### 10.3.1 向操作系统求助 -子进程终止的识别主体是操作系统,因此,若操作系统能把如下信息告诉正忙于工作的父进程,将有助于构建更高效的程序 +子进程终止的识别主题是操作系统,因此,若操作系统能把子进程结束的信息告诉正忙于工作的父进程,将有助于构建更高效的程序 为了实现上述的功能,引入信号处理机制(Signal Handing)。此处「信号」是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程被称为「处理」或「信号处理」。 @@ -2514,7 +2768,7 @@ gcc waitpid.c -o waitpid > 进程:操作系统,如果我之前创建的子进程终止,就帮我调用 zombie_handler 函数。 > -> 操作系统:好的,如果你的子进程终止,我舅帮你调用 zombie_handler 函数,你先把要函数要执行的语句写好。 +> 操作系统:好的,如果你的子进程终止,我就帮你调用 zombie_handler 函数,你先把函数要执行的语句写好。 上述的对话,相当于「注册信号」的过程。即进程发现自己的子进程结束时,请求操作系统调用的特定函数。该请求可以通过如下函数调用完成: @@ -2539,7 +2793,7 @@ void (*signal(int signo, void (*func)(int)))(int); > 「子进程终止则调用 mychild 函数」 -此时 mychild 函数的参数应为 int ,返回值类型应为 void 。只有这样才能称为 signal 函数的第二个参数。另外,常数 SIGCHLD 定义了子进程终止的情况,应成为 signal 函数的第一个参数。也就是说,signal 函数调用语句如下: +此时 mychild 函数的参数应为 int ,返回值类型应为 void 。只有这样才能成为 signal 函数的第二个参数。另外,常数 SIGCHLD 定义了子进程终止的情况,应成为 signal 函数的第一个参数。也就是说,signal 函数调用语句如下: ```c signal(SIGCHLD , mychild); @@ -2565,7 +2819,7 @@ unsigned int alarm(unsigned int seconds); // 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间 ``` -如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果通过改函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。 +如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递 0,则之前对 SIGALRM 信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。 - [signal.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch10/signal.c) @@ -2619,17 +2873,17 @@ gcc signal.c -o signal > 发生信号时将唤醒由于调用 sleep 函数而进入阻塞状态的进程。 -调用函数的主题的确是操作系统,但是进程处于睡眠状态时无法调用函数,因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。 +调用函数的主体的确是操作系统,但是进程处于睡眠状态时无法调用函数,因此,产生信号时,为了调用信号处理器,将唤醒由于调用 sleep 函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态。即使还未到 sleep 中规定的时间也是如此。所以上述示例运行不到 10 秒后就会结束,连续输入 CTRL+C 可能连一秒都不到。 **简言之,就是本来系统要睡眠100秒,但是到了 alarm(2) 规定的两秒之后,就会唤醒睡眠的进程,进程被唤醒了就不会再进入睡眠状态了,所以就不用等待100秒。如果把 timeout() 函数中的 alarm(2) 注释掉,就会先输出`wait...`,然后再输出`Time out!` (这时已经跳过了第一次的 sleep(100) 秒),然后就真的会睡眠100秒,因为没有再发出 alarm(2) 的信号。** #### 10.3.3 利用 sigaction 函数进行信号处理 -前面所学的内容可以防止僵尸进程,还有一个函数,叫做 sigaction 函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为: +前面所学的内容可以防止僵尸进程,还有一个函数叫做 sigaction 函数,它类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为: > signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同 -实际上现在很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容,下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。 +实际上现在很少用 signal 函数编写程序,它只是为了保持对旧程序的兼容。下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。 ```c #include @@ -2676,7 +2930,7 @@ int main(int argc, char *argv[]) int i; struct sigaction act; act.sa_handler = timeout; //保存函数指针 - sigemptyset(&act.sa_mask); //将 sa_mask 函数的所有位初始化成0 + sigemptyset(&act.sa_mask); //将 sa_mask 成员的所有位初始化成0 act.sa_flags = 0; //sa_flags 同样初始化成 0 sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。 @@ -2804,7 +3058,7 @@ Child send: 24 wait ``` -请自习观察结果,结果中的每一个空行代表间隔了5 秒,程序是先创建了两个子进程,然后子进程 10 秒之后会返回值,第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 +请仔细观察结果,结果中的每一个空行代表间隔了 5 秒。程序先创建了两个子进程,子进程在 10 秒之后会返回值。第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡眠 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 所以在本程序的过程中,当子进程终止时候,会向系统发送一个信号,然后调用我们提前写好的处理函数,在处理函数中使用 waitpid 来处理僵尸进程,获取子进程返回值。 @@ -2843,11 +3097,11 @@ gcc echo_mpserv.c -o eserver 示例中给出了通过 fork 函数复制文件描述符的过程。父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。 -调用 fork 函数时复制父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。 +调用 fork 函数时赋值父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。 ![](https://s2.ax1x.com/2019/01/21/kP7Rjx.png) -如图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数候,要将无关紧要的套接字文件描述符关掉,如图所示: +如图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数后,要将无关紧要的套接字文件描述符关掉,如图所示: ![](https://s2.ax1x.com/2019/01/21/kPH7ZT.png) @@ -2865,13 +3119,13 @@ gcc echo_mpserv.c -o eserver 从图中可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。 -分割 I/O 程序的另外一个好处是,可以提高频繁交换数据的程序性能,图下图所示: +分割 I/O 程序的另外一个好处是,可以提高频繁交换数据的程序性能,如下图所示: ![](https://s2.ax1x.com/2019/01/21/kPbvtg.png) -根据上图显示可以看出,再网络不好的情况下,明显提升速度。 +根据上图显示可以看出,在网络不好的情况下,明显提升速度。 #### 10.5.2 回声客户端的 I/O 程序分割 @@ -2896,25 +3150,25 @@ gcc echo_mpclient.c -o eclient ### 10.6 习题 -> 以下答案仅代表本人个人观点,可能不是正确答案。 +> 以下答案仅代表本人个人观点,可能不是标准答案。 1. **下列关于进程的说法错误的是?** - 答:以下加粗的内容为正确的 + 答:说法 3 和说法 4 是错误的。 - 1. **从操作系统的角度上说,进程是程序运行的单位** - 2. 进程根据创建方式建立父子关系 - 3. **进程可以包含其他进程,即一个进程的内存空间可以包含其他进程** - 4. **子进程可以创建其他子进程,而创建出来的子进程还可以创建其他子进程,但所有这些进程只与一个父进程建立父子关系。** + - 说法 1:从操作系统的角度上说,进程是程序运行的单位。(正确) + - 说法 2:进程根据创建方式建立父子关系。(正确) + - 说法 3:**进程可以包含其他进程,即一个进程的内存空间可以包含其他进程。**(错误)进程拥有独立的内存空间,一个进程不能直接包含另一个进程的内存空间。 + - 说法 4:**子进程可以创建其他子进程,而创建出来的子进程还可以创建其他子进程,但所有这些进程只与一个父进程建立父子关系。**(错误)一个进程可以有多个子进程,但每个子进程只有一个父进程。子进程创建的孙进程与原父进程没有直接的父子关系。 -2. **调用 fork 函数将创建子进程,一下关于子进程错误的是?** +2. **调用 fork 函数将创建子进程,以下关于子进程错误的是?** - 答:以下加粗的内容为正确的 + 答:说法 1、说法 3 和说法 4 是错误的。 - 1. **父进程销毁时也会同时销毁子进程** - 2. **子进程是复制父进程所有资源创建出的进程** - 3. 父子进程共享全局变量 - 4. 通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。 + - 说法 1:**父进程销毁时也会同时销毁子进程。**(错误)父进程销毁时,子进程不会自动销毁,而是成为孤儿进程,被 init 进程(PID 为 1)收养。 + - 说法 2:子进程是复制父进程所有资源创建出的进程。(正确) + - 说法 3:**父子进程共享全局变量。**(错误)fork 之后,父子进程拥有各自独立的全局变量副本,修改其中一个不会影响另一个。 + - 说法 4:**通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。**(错误)子进程从 fork 函数调用后的语句开始执行,不会重新执行之前的代码。 3. **创建子进程时复制父进程所有内容,此时复制对象也包含套接字文件描述符。编写程序验证复制的文件描述符整数值是否与原文件描述符数值相同。** @@ -2924,12 +3178,20 @@ gcc echo_mpclient.c -o eclient ![](https://s2.ax1x.com/2019/01/21/kPj3Md.png) - 从图上可以看出,数值相同。 + 从图上可以看出,数值相同。fork 复制文件描述符时,子进程获得的文件描述符整数值与父进程的相同。 4. **请说明进程变为僵尸进程的过程以及预防措施。** - 答:当一个父进程以fork()系统调用建立一个新的子进程后,核心进程就会在进程表中给这个子进程分配一个进入点,然后将相关信息存储在该进入点所对应的进程表内。这些信息中有一项是其父进程的识别码。而当这个子进程结束的时候(比如调用exit命令结束),其实他并没有真正的被销毁,而是留下一个称为僵尸进程(Zombie)的数据结构(系统调用exit的作用是使进程退出,但是也仅仅限于一个正常的进程变成了一个僵尸进程,并不能完全将其销毁)。**预防措施**:通过 wait 和 waitpid 函数加上信号函数写代码来预防。 + 答:当一个父进程通过 fork() 系统调用创建一个新的子进程后,操作系统会在进程表中给这个子进程分配一个表项,并将相关信息(包括父进程的 PID)存储在该表项中。 + 当子进程结束时(比如调用 exit() 函数或 main 函数 return),它并不会立即被完全销毁,而是保留一个称为「僵尸进程」的数据结构在进程表中。这个僵尸进程包含着子进程的退出状态信息,等待父进程读取。系统调用 exit 的作用是使进程退出,但这只是将进程转变为僵尸状态,并不能完全将其销毁。 + + **僵尸进程的产生条件**:子进程先于父进程终止,而父进程没有调用 wait() 或 waitpid() 来回收子进程的退出状态。 + + **预防措施**: + - 在父进程中调用 wait() 或 waitpid() 函数来回收子进程的退出状态 + - 使用信号处理机制:注册 SIGCHLD 信号处理函数,当子进程终止时,操作系统会向父进程发送 SIGCHLD 信号,在信号处理函数中调用 waitpid() 来回收僵尸进程 + - 如果父进程不需要关心子进程的退出状态,可以显式忽略 SIGCHLD 信号(通过 signal(SIGCHLD, SIG_IGN)),这样子进程终止后会被系统自动回收 ## 第 11 章 进程间通信 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -2956,7 +3218,7 @@ filedes[1]: 通过管道传输数据时使用的文件描述符,即管道入 */ ``` -父进程创建函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。下面的例子是关于该函数的使用方法: +父进程调用函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。下面的例子是关于该函数的使用方法: - [pipe1.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch11/pipe1.c) @@ -3045,7 +3307,6 @@ int main(int argc, char *argv[]) } return 0; } - ``` 编译运行: @@ -3062,7 +3323,7 @@ Parent proc output: Who are you? Child proc output: Thank you for your message ``` -运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。 +运行结果是正确的,但是如果注释掉代码中子进程里的 `sleep(2);`(第18行),就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释掉 `sleep(2);` 会产生问题。子进程可能读回自己向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。 当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动,过程如下图所示: @@ -3130,7 +3391,7 @@ gcc echo_storeserv.c -o serv ![](https://s2.ax1x.com/2019/01/22/kFUAHS.png) -从图上可以看出,服务端已经生成了文件,把客户端的消息保存可下来,只保存了10次消息。 +从图上可以看出,服务端已经生成了文件,把客户端的消息保存了下来,只保存了10次消息。 ### 11.3 习题 @@ -3138,11 +3399,11 @@ gcc echo_storeserv.c -o serv 1. **什么是进程间通信?分别从概念和内存的角度进行说明。** - 答:进程间通信意味着两个不同的进程间可以交换数据。从内存上来说,就是两个进程可以访问同一个内存区域,然后通过这个内存区域数据的变化来进行通信。 + 答:进程间通信(Inter-Process Communication,IPC)是指两个不同的进程间可以交换数据的机制。从概念上讲,它允许独立运行的进程之间传递信息、同步操作。从内存角度来说,由于每个进程都有自己独立的内存地址空间,进程间无法直接访问彼此的内存,因此需要操作系统提供特殊的共享内存区域或通信机制(如管道、共享内存、消息队列等),通过这个内核提供的缓冲区来进行数据交换。 2. **进程间通信需要特殊的 IPC 机制,这是由于操作系统提供的。进程间通信时为何需要操作系统的帮助?** - 答:为了进行进程间通信,需要管道的帮助,但是管道不是进程的资源,它属于从操作系统,所以,两个进程通过操作系统提供的内存空间进行通信。 + 答:进程间通信需要操作系统的帮助是因为:每个进程都有独立的内存地址空间,进程之间无法直接访问对方的内存。IPC 机制(如管道)属于操作系统内核管理的资源,而非单个进程的资源。通过 fork 函数复制的是文件描述符,而非管道本身。因此,两个进程必须通过操作系统提供的内核缓冲区来实现数据交换。 3. **「管道」是典型的 IPC 技法。关于管道,请回答以下问题:** @@ -3156,8 +3417,7 @@ gcc echo_storeserv.c -o serv 3. **管道允许 2 个进程间的双向通信。双向通信中需要注意哪些内容?** - 答:向管道传输数据时,先读的进程会把数据取走。简言之,就是数据进入管道候会变成无主数据,所以有时候为了防止错误,需要多个管道来进程通信。 - + 答:向管道传输数据时,先调用 read 函数的进程会把数据取走。换言之,数据进入管道后会变成无主数据,任何连接到该管道的进程都可以读取。因此,在使用单个管道进行双向通信时,一个进程可能会读取到自己写入的数据,导致另一个进程无限等待。为了避免这个问题,通常需要创建两个管道,各自负责不同方向的数据流动。 ## 第 12 章 I/O 复用 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -3213,11 +3473,6 @@ I/O 复用技术可以解决这个问题。 从图上可以看出,引入复用技术之后,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有一个。 -**知识补给站**「关于复用服务器端的另一种理解」 ->某教室中有10名学生和1位教师,这些孩子并非等闲之辈,上课时不停地提问。学校没办法,只能给毎个学生都配1位教师,也就是说教室中现有10位教师。此后,只要有新的转校生,就会增加1位教师,因为转校生也喜欢提问。这个故事中,如果把学生当作客户端,把教师当作与客户端进行数据交换的服务器端进程,则该教室的运营方式为多进程服务器端方式。 -有一天,该校来了位具有超能力的教师。这位教师可以应对所有学生的提问,而且回答速度很快,不会让学生等待。因此,学校为了提高教师效率,将其他老师转移到了别的班。现在,学生提问前必须举手,教师确认举手学生的提问后再回答问题。也就是说,现在的教室以I/O复用方式运行。 -虽然例子有些奇怪,但可以通过它理解I/O复用技法:教师必须确认有无举手学生 -同样,I/O复用服务器端的进程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。 ### 12.2 理解 select 函数并实现服务端 select 函数是最具代表性的实现复用服务器的方法。在 Windows 平台下也有同名函数,所以具有很好的移植性。 @@ -3248,7 +3503,7 @@ select 函数的使用方法与一般函数的区别并不大,更准确的说 - `FD_ZERO(fd_set *fdset)`:将 fd_set 变量所指的位全部初始化成0 - `FD_SET(int fd,fd_set *fdset)`:在参数 fdset 指向的变量中注册文件描述符 fd 的信息 -- `FD_SLR(int fd,fd_set *fdset)`:从参数 fdset 指向的变量中清除文件描述符 fd 的信息 +- `FD_CLR(int fd,fd_set *fdset)`:从参数 fdset 指向的变量中清除文件描述符 fd 的信息 - `FD_ISSET(int fd,fd_set *fdset)`:若参数 fdset 指向的变量中包含文件描述符 fd 的信息,则返回「真」 上述函数中,FD_ISSET 用于验证 select 函数的调用结果,通过下图解释这些函数的功能: @@ -3272,7 +3527,7 @@ readset: 将所有关注「是否存在待读取数据」的文件描述符注 writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。 exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。 timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息 -返回值: 发生错误时返回 -1,超时时返回0,。因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。 +返回值: 发生错误时返回 -1,超时时返回 0。因发生关注的事件返回时,返回大于 0 的值,该值是发生事件的文件描述符数。 */ ``` @@ -3293,7 +3548,7 @@ struct timeval }; ``` -本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微妙数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。 +本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微秒数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select 函数返回 0。因此,可以通过返回值了解原因。如果不想设置超时,则传递 NULL 参数。 #### 12.2.4 调用 select 函数查看结果 @@ -3301,7 +3556,7 @@ select 返回正整数时,怎样获知哪些文件描述符发生了变化? ![](https://s2.ax1x.com/2019/01/23/kA06dx.png) -由图可知,select 函数调用完成候,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。 +由图可知,select 函数调用完成后,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化。 #### 12.2.5 select 函数调用示例 @@ -3351,7 +3606,7 @@ gcc echo_selectserv.c -o selserv 1. **请解释复用技术的通用含义,并说明何为 I/O 复用。** - 答:通用含义:在 1 个通信频道中传递多个数据(信号)的技术。IO复用就是进程预先告诉内核需要监视的IO条件,使得内核一旦发现进程指定的一个或多个IO条件就绪,就通过进程进程处理,从而不会在单个IO上阻塞了。 + 答:通用含义:在 1 个通信频道中传递多个数据(信号)的技术。I/O 复用是指进程预先告诉内核需要监视的 I/O 条件,使得内核一旦发现进程指定的一个或多个 I/O 条件就绪(例如套接字变为可读或可写),就通知进程进行处理,从而进程不会在单个 I/O 操作上阻塞。 参考文章:[Linux网络编程-IO复用技术](https://www.cnblogs.com/luoxn28/p/6220372.html) @@ -3359,19 +3614,18 @@ gcc echo_selectserv.c -o selserv 答:多进程需要进行大量的运算和大量的内存空间。在 I/O 复用服务器中通过 select 函数监视文件描述符,通过判断变化的文件描述符,来得知变化的套接字是哪个,从而实时应答来自多个客户端的请求。 -3. **复用服务器端需要 select 函数。下列关于 select 函数使用方法的描述错误的是?** +3. **复用服务器端需要 select 函数。下列关于 select 函数使用方法的描述错误的是?** - 答:以下加粗的为正确的描述。 + 答:错误的描述是 2 和 3。说明如下: - 1. 调用 select 函数前需要集中 I/O 监视对象的文件描述符 - 2. **若已通过 select 函数注册为监视对象,则后续调用 select 函数时无需重复注册** - 3. 复用服务器端同一时间只能服务于 1 个客户端,因此,需要服务的客户端接入服务器端后只能等待 - 4. **与多进程服务端不同,基于 select 的复用服务器只需要 1 个进程。因此,可以减少因创建多进程产生的服务器端的负担**。 + - 描述 1 正确:调用 select 函数前需要集中 I/O 监视对象的文件描述符。 + - 描述 2 错误:**每次调用 select 函数时都需要重新注册监视对象**,因为 select 函数调用后,fd_set 变量会被修改,只保留发生变化的文件描述符位为 1。 + - 描述 3 错误:**复用服务器端可以同时服务于多个客户端**,这正是 I/O 复用的核心优势——通过 select 函数同时监视多个套接字,哪个就绪就处理哪个。 + - 描述 4 正确:与多进程/多线程服务端不同,基于 select 的复用服务器只需要 1 个进程,可以减少因创建多进程产生的服务器端的负担。 4. **select 函数的观察对象中应包含服务端套接字(监听套接字),那么应将其包含到哪一类监听对象集合?请说明原因**。 - 答:应该包含到「是否存在待读取数据」,因为服务器端需要查看套接字中有没有可以读取的数据。 - + 答:应该包含到「是否存在待读取数据」(即 readset)集合中。原因是:服务端套接字(监听套接字)的作用是监听客户端的连接请求,当有新的客户端发起连接时,监听套接字变为「可读」状态,此时 accept 函数不会阻塞,可以成功完成连接。因此需要监视监听套接字是否可读,以得知是否有新的连接请求到来。 ## 第 13 章 多种 I/O 函数 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -3388,7 +3642,7 @@ ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags); /* 成功时返回发送的字节数,失败时返回 -1 sockfd: 表示与数据传输对象的连接的套接字和文件描述符 -buf: 保存带传输数据的缓冲地址值 +buf: 保存待传输数据的缓冲地址值 nbytes: 待传输字节数 flags: 传输数据时指定的可选项信息 */ @@ -3401,24 +3655,24 @@ flags: 传输数据时指定的可选项信息 ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags); /* 成功时返回接收的字节数(收到 EOF 返回 0),失败时返回 -1 -sockfd: 表示数据接受对象的连接的套接字文件描述符 -buf: 保存接受数据的缓冲地址值 +sockfd: 表示数据接收对象的连接的套接字文件描述符 +buf: 保存接收数据的缓冲地址值 nbytes: 可接收的最大字节数 flags: 接收数据时指定的可选项参数 */ ``` -send 和 recv 函数都是最后一个参数是收发数据的可选项,该选项可以用位或(bit OR)运算符(| 运算符)同时传递多个信息。 +send 和 recv 函数的最后一个参数是收发数据的可选项,该选项可以用位或(bit OR)运算符(| 运算符)同时传递多个信息。 send & recv 函数的可选项意义: -| 可选项(Option) | 含义 | send | recv | -| ---------------- | -------------------------------------------------------------------------- | ---- | ---- | -| MSG_OOB | 用于传输带外数据(Out-of-band data) | O | O | -| MSG_PEEK | 验证输入缓冲中是否存在接受的数据 | X | O | +| 可选项(Option) | 含义 | send | recv | +| ---------------- | ------------------------------------------------------------ | ---- | ---- | +| MSG_OOB | 用于传输带外数据(Out-of-band data) | O | O | +| MSG_PEEK | 验证输入缓冲中是否存在接收的数据 | X | O | | MSG_DONTROUTE | 数据传输过程中不参照本地路由(Routing)表,在本地(Local)网络中寻找目的地 | O | X | -| MSG_DONTWAIT | 调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/O | O | O | -| MSG_WAITALL | 防止函数返回,直到接收到全部请求的字节数 | X | O | +| MSG_DONTWAIT | 调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/O | O | O | +| MSG_WAITALL | 防止函数返回,直到接收到全部请求的字节数 | X | O | #### 13.1.2 MSG_OOB:发送紧急消息 @@ -3460,7 +3714,7 @@ fcntl(recv_sock, F_SETOWN, getpid()); 的确,通过 MSG_OOB 并不会加快传输速度,而通过信号处理函数 urg_handler 也只能读取一个字节。剩余数据只能通过未设置 MSG_OOB 可选项的普通输入函数读取。因为 TCP 不存在真正意义上的「外带数据」。实际上,MSG_OOB 中的 OOB 指的是 Out-of-band ,而「外带数据」的含义是: -> 通过去完全不同的通信路径传输的数据 +> 通过完全不同的通信路径传输的数据 即真正意义上的 Out-of-band 需要通过单独的通信路径高速传输数据,但是 TCP 不另外提供,只利用 TCP 的紧急模式(Urgent mode)进行传输。 @@ -3476,7 +3730,7 @@ MSG_OOB 的真正意义在于督促数据接收对象尽快处理数据。这是 send(sock, "890", strlen("890"), MSG_OOB); ``` -图上是调用这个函数的缓冲状态。如果缓冲最左端的位置视作偏移量 0 。字符 0 保存于偏移量 2 的位置。另外,字符 0 右侧偏移量为 3 的位置存有紧急指针(Urgent Pointer)。紧急指针指向紧急消息的下一个位置(偏移量加一),同时向对方主机传递一下信息: +图上是调用这个函数的缓冲状态。如果缓冲最左端的位置视作偏移量 0 。字符 0 保存于偏移量 2 的位置。另外,字符 0 右侧偏移量为 3 的位置存有紧急指针(Urgent Pointer)。紧急指针指向紧急消息的下一个位置(偏移量加一),同时向对方主机传递以下信息: > 紧急指针指向的偏移量为 3 之前的部分就是紧急消息。 @@ -3491,19 +3745,18 @@ TCP 数据包实际包含更多信息。TCP 头部包含如下两种信息: 指定 MSG_OOB 选项的数据包本身就是紧急数据包,并通过紧急指针表示紧急消息所在的位置。 -如前所述,除紧急指针的前面1个字节外,数据接收方将通过调用常用输入函数读取剩余部分。 -换言之,**紧急消息的意义在于督促消息处理,而非紧急传输形式受限的消息**。 +紧急消息的意义在于督促消息处理,而非紧急传输形式受限的信息。 #### 13.1.4 检查输入缓冲 -同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。因此,该选项通常与 MSG_DONTWAIT 合作,用于调用以非阻塞方式验证待读数据存与否的函数。下面的示例是二者的含义: +同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。因此,该选项通常与 MSG_DONTWAIT 配合,用于以非阻塞方式验证待读数据存在与否。下面的示例是二者的含义: - [peek_recv.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch13/peek_recv.c) - [peek_send.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch13/peek_send.c) 编译运行: -``` +```shell gcc peek_recv.c -o recv gcc peek_send.c -o send ./recv 9190 @@ -3531,7 +3784,7 @@ readv & writev 函数的功能可概括如下: ssize_t writev(int filedes, const struct iovec *iov, int iovcnt); /* 成功时返回发送的字节数,失败时返回 -1 -filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符. +filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向其传递文件或标准输出描述符. iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息 iovcnt: 向第二个参数传递数组长度 */ @@ -3552,8 +3805,6 @@ struct iovec ![](https://i.loli.net/2019/01/26/5c4c61b07d207.png) writev 的第一个参数,是文件描述符,因此向控制台输出数据,ptr 是存有待发送数据信息的 iovec 数组指针。第三个参数为 2,因此,从 ptr 指向的地址开始,共浏览 2 个 iovec 结构体变量,发送这些指针指向的缓冲数据。 -`例如`ptr[0](数组第一个元素)的 iov_base指向以A开头的字符串,同时 iov_len为3,故发送ABC。 -而ptr[1](数组的第二个元素)的iov_base指向数字1,同时 iov_len为4,故发送1234。 下面是 writev 函数的使用方法: @@ -3585,7 +3836,7 @@ int main(int argc, char *argv[]) ```shell gcc writev.c -o writev -./writevi +./writev ``` 结果: @@ -3599,12 +3850,12 @@ Write bytes: 7 ```c #include -ssize_t readv(int filedes, const struct iovc *iov, int iovcnt); +ssize_t readv(int filedes, const struct iovec *iov, int iovcnt); /* 成功时返回接收的字节数,失败时返回 -1 -filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符. -iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息 -iovcnt: 向第二个参数传递数组长度 +filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 write 一样向其传递文件或标准输出描述符. +iov: iovec 结构体数组的地址值,结构体 iovec 中包含待数据保存的位置和大小信息 +iovcnt: 第二个参数中数组的长度 */ ``` @@ -3665,12 +3916,63 @@ gcc readv.c -o rv ### 13.3 基于 Windows 的实现 -暂略 +Windows 下的 Winsock 提供了与 Linux 类似的 I/O 函数,主要有以下区别: + +#### 13.3.1 send 和 recv 函数 + +Winsock 中的 send 和 recv 函数原型与 Linux 基本一致: + +```c +#include +int send(SOCKET s, const char *buf, int len, int flags); +int recv(SOCKET s, char *buf, int len, int flags); +``` + +主要区别: +- 参数类型:Linux 使用 `int sockfd`,Windows 使用 `SOCKET s`(实际是 `typedef UINT_PTR SOCKET;`) +- 缓冲区类型:Linux 使用 `void *`,Windows 使用 `char *` +- 返回值:Linux 返回 `ssize_t`,Windows 返回 `int`(失败时返回 `SOCKET_ERROR`,即 -1) + +#### 13.3.2 WSASend、WSARecv 和 WSARecvEx + +Windows 还提供了扩展版本的异步 I/O 函数: + +```c +int WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, + LPDWORD lpNumberOfBytesSent, DWORD dwFlags, + LPWSAOVERLAPPED lpOverlapped, + LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); + +int WSARecv(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, + LPDWORD lpNumberOfBytesRecvd, LPDWORD lpFlags, + LPWSAOVERLAPPED lpOverlapped, + LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); +``` + +这些函数支持重叠 I/O(Overlapped I/O)和完成端口(Completion Port)模型,适合高性能服务器开发。 + +#### 13.3.3 可选项差异 + +Windows 与 Linux 在可选项上存在一些差异: + +| 可选项 | Linux | Windows | +| ------ | ----- | ------- | +| MSG_OOB | 支持 | 支持 | +| MSG_PEEK | 支持 | 支持 | +| MSG_DONTWAIT | 支持 | 不支持(需通过 ioctlsocket 设置非阻塞模式) | +| MSG_WAITALL | 支持 | 支持 | +| MSG_PARTIAL | 不支持 | 支持(仅用于流式套接字) | + +Windows 不支持 MSG_DONTWAIT,需要通过 `ioctlsocket` 函数设置套接字为非阻塞模式: + +```c +u_long mode = 1; +ioctlsocket(sock, FIONBIO, &mode); +``` ### 13.4 习题 > 以下答案仅代表本人个人观点,可能不是正确答案。 -> 1. **下列关于 MSG_OOB 可选项的说法错误的是**? @@ -3685,10 +3987,28 @@ gcc readv.c -o rv 答:需要传输的数据分别位于不同缓冲(数组)时,需要多次调用 write 函数。此时可通过 1 次 writev 函数调用替代操作,当然会提高效率。同样,需要将输入缓冲中的数据读入不同位置时,可以不必多次调用 read 函数,而是利用 1 次 readv 函数就能大大提高效率。 + 从 I/O 缓冲的角度来看,writev 函数可以将分散的数据整合为一次系统调用,减少用户态与内核态之间的上下文切换次数,同时减少网络数据包的个数(尤其在禁用 Nagle 算法时效果更明显)。 + 3. **通过 recv 函数验证输入缓冲中是否存在数据时(确认后立即返回时),如何设置 recv 函数最后一个参数中的可选项?分别说明各可选项的含义**。 - 答:使用 MSG_PEEK 来验证输入缓冲中是否存在待接收的数据。各个可选项的意义参见上面对应章节的表格。 + 答:应同时设置 `MSG_PEEK` 和 `MSG_DONTWAIT` 两个可选项。 + 各可选项的含义: + + - **MSG_PEEK**:验证输入缓冲中是否存在待接收的数据。设置此选项后,recv 函数会读取输入缓冲中的数据但不会将其删除(数据仍保留在缓冲中),可以再次读取。 + - **MSG_DONTWAIT**:调用 I/O 函数时不阻塞,用于非阻塞(Non-blocking)I/O。设置此选项后,如果输入缓冲中没有数据,recv 函数会立即返回错误(errno 设为 EAGAIN 或 EWOULDBLOCK),而不是阻塞等待。 + + 示例代码: + ```c + int len = recv(sockfd, buf, sizeof(buf), MSG_PEEK | MSG_DONTWAIT); + if (len > 0) { + // 缓冲中有数据 + } else if (len == 0) { + // 连接已关闭 + } else { + // 无数据或出错 + } + ``` ## 第 14 章 多播与广播 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -3730,11 +4050,11 @@ int send_sock; int time_live = 64; ... send_sock=socket(PF_INET,SOCK_DGRAM,0); -setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live,sizeof(time_live); +setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live,sizeof(time_live)); ... ``` -加入多播组也通过设置设置套接字可选项来完成。加入多播组相关的协议层为 IPPROTO_IP,选项名为 IP_ADD_MEMBERSHIP 。可通过如下代码加入多播组: +加入多播组也通过设置套接字可选项来完成。加入多播组相关的协议层为 IPPROTO_IP,选项名为 IP_ADD_MEMBERSHIP 。可通过如下代码加入多播组: ```c int recv_sock; @@ -3744,7 +4064,7 @@ recv_sock=socket(PF_INET,SOCK_DGRAM,0); ... join_adr.imr_multiaddr.s_addr="多播组地址信息"; join_adr.imr_interface.s_addr="加入多播组的主机地址信息"; -setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(time_live); +setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(join_adr)); ... ``` @@ -3762,7 +4082,7 @@ struct ip_mreq 多播中用「发送者」(以下称为 Sender) 和「接收者」(以下称为 Receiver)替代服务器端和客户端。顾名思义,此处的 Sender 是多播数据的发送主体,Receiver 是需要多播组加入过程的数据接收主体。下面是示例,示例的运行场景如下: -- Sender : 向 AAA 组广播(Broadcasting)文件中保存的新闻信息 +- Sender : 向 AAA 组多播(Multicasting)文件中保存的新闻信息 - Receiver : 接收传递到 AAA 组的新闻信息。 下面是两个代码: @@ -3834,7 +4154,67 @@ gcc news_sender_brd.c -o sender ### 14.3 基于 Windows 的实现 -暂略 +Windows 平台下的多播和广播实现与 Linux 类似,主要区别在于套接字操作相关的头文件和函数调用。 + +#### 14.3.1 Windows 多播实现 + +在 Windows 中实现多播 Sender 时,需要包含 `winsock2.h` 头文件,并链接 `ws2_32.lib` 库。主要代码区别如下: + +```c +#include +#include +#pragma comment(lib, "ws2_32.lib") + +// Windows 下的套接字初始化 +WSADATA wsaData; +if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) + error_handling("WSAStartup() error"); + +// 创建套接字 +SOCKET send_sock; +send_sock = socket(AF_INET, SOCK_DGRAM, 0); + +// 设置 TTL +int time_live = 64; +setsockopt(send_sock, IPPROTO_IP, IP_MULTICAST_TTL, (char*)&time_live, sizeof(time_live)); + +// ... 发送数据 ... + +// Windows 下的套接字关闭 +closesocket(send_sock); +WSACleanup(); +``` + +Windows 下实现多播 Receiver 时,加入多播组的代码如下: + +```c +struct ip_mreq join_adr; +join_adr.imr_multiaddr.s_addr = inet_addr(group_ip); +join_adr.imr_interface.s_addr = htonl(INADDR_ANY); +setsockopt(recv_sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char*)&join_adr, sizeof(join_adr)); +``` + +#### 14.3.2 Windows 广播实现 + +Windows 下实现广播时,启用广播的代码如下: + +```c +SOCKET send_sock; +send_sock = socket(AF_INET, SOCK_DGRAM, 0); + +int bcast = 1; +setsockopt(send_sock, SOL_SOCKET, SO_BROADCAST, (char*)&bcast, sizeof(bcast)); + +// ... 发送数据 ... +``` + +与 Linux 的主要区别总结: + +1. 头文件:Windows 使用 `winsock2.h`,Linux 使用 `sys/socket.h` 等头文件 +2. 初始化:Windows 需要调用 `WSAStartup` 初始化 Winsock,使用完后调用 `WSACleanup` +3. 套接字类型:Windows 使用 `SOCKET` 类型(实际是 `unsigned __int64`),Linux 使用 `int` +4. 关闭套接字:Windows 使用 `closesocket`,Linux 使用 `close` +5. 指针类型转换:Windows 下 `setsockopt` 的第四个参数通常转换为 `char*`,Linux 下转换为 `void*` ### 14.4 习题 @@ -3846,16 +4226,16 @@ gcc news_sender_brd.c -o sender 2. **多播与广播的异同点是什么?请从数据通信的角度进行说明**。 - 答:在「一次性向多个主机发送数据」这一点上与多播类似,但传输的数据范围有区别。多播即使在跨越不同网络的情况下,只要加入多播组就能接受数据。相反,广播只能向同意网络中的主机传输数据。 + 答:在「一次性向多个主机发送数据」这一点上与多播类似,但传输的数据范围有区别。多播即使在跨越不同网络的情况下,只要加入多播组就能接受数据。相反,广播只能向同一网络中的主机传输数据。 3. **下面关于多播的说法描述错误的是**? - 答:以下内容加粗的为描述正确 + 答:第 2 项描述错误。正确说明如下: - 1. 多播是用来加入多播组的所有主机传输数据的协议 - 2. 主机连接到同一网络才能加入到多播组,也就是说,多播组无法跨越多个网络 - 3. **能够加入多播组的主机数并无限制,但只能有 1个主机(Sender)向该组发送数据** - 4. **多播时使用的套接字是 UDP 套接字,因为多播是基于 UDP 进行数据通信的。** + 1. 多播是用来向加入多播组的所有主机传输数据的协议 + 2. ~~主机连接到同一网络才能加入到多播组,也就是说,多播组无法跨越多个网络~~(错误。多播可以跨越多个网络,只要路由器支持多播功能,主机就可以加入跨网络的多播组。即使路由器不支持,也可以通过隧道技术实现。) + 3. 能够加入多播组的主机数并无限制,但只能有 1 个主机(Sender)向该组发送数据 + 4. 多播时使用的套接字是 UDP 套接字,因为多播是基于 UDP 进行数据通信的 4. **多播也对网络流量有利,请比较 TCP 交换方式解释其原因** @@ -3863,8 +4243,7 @@ gcc news_sender_brd.c -o sender 5. **多播方式的数据通信需要 MBone 虚拟网络。换言之,MBone 是用于多播的网络,但它是虚拟网络。请解释此处的「虚拟网络」** - 答:可以理解为「通过网络中的特殊协议工作的软件概念上的网络」。也就是说, MBone 并非可以触及的物理网络。他是以物理网络为基础,通过软件方法实现的多播通信必备虚拟网络。 - + 答:可以理解为「通过网络中的特殊协议工作的软件概念上的网络」。也就是说,MBone 并非可以触及的物理网络。它是以物理网络为基础,通过软件方法(隧道技术)实现的多播通信必备虚拟网络。MBone(Multicast Backbone)是互联网的多播骨干网,通过在支持多播的路由器之间建立隧道,将不支持多播的网络连接起来,从而构建一个覆盖全球的虚拟多播网络。 ## 第 15 章 套接字和标准I/O 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -3873,7 +4252,7 @@ gcc news_sender_brd.c -o sender #### 15.1.1 标准 I/O 函数的两个优点 -下面是标准 I/O 函数的两个优点: +除了使用 read 和 write 函数收发数据外,还能使用标准 I/O 函数收发数据。下面是标准 I/O 函数的两个优点: - 标准 I/O 函数具有良好的移植性 - 标准 I/O 函数可以利用缓冲提高性能 @@ -3889,7 +4268,7 @@ gcc news_sender_brd.c -o sender - 传输的数据量 - 数据向输出缓冲移动的次数。 -比较 1 个字节的数据发送 10 次的情况和 10 个数据包发送 1 次的情况。发送数据时,数据包中含有头信息。头信与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别: +比较 1 个字节的数据发送 10 次的情况和 10 个字节发送 1 次的情况。发送数据时,数据包中含有头信息。头信息与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别: - 1 个字节 10 次:40*10=400 字节 - 10个字节 1 次:40*1=40 字节。 @@ -3904,7 +4283,7 @@ gcc news_sender_brd.c -o sender - [stdcpy.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch15/stdcpy.c) -对于以上两个代码进行测试,明显基于标准 I/O 函数的代码跑的更快 +对于以上两个代码进行测试,明显基于标准 I/O 函数的代码跑的更快。这是因为标准 I/O 函数通过缓冲区减少了系统调用的次数,每次系统调用都有一定的开销(用户态与内核态的切换),而缓冲机制可以将多次小数据量的 I/O 操作合并为较少次数的系统调用,从而提高性能。 #### 15.1.3 标准 I/O 函数的几个缺点 @@ -3912,7 +4291,6 @@ gcc news_sender_brd.c -o sender - 不容易进行双向通信 - 有时可能频繁调用 fflush 函数 ->fflush—切换读写工作状态 - 需要以 FILE 结构体指针的形式返回文件描述符。 ### 15.2 使用标准 I/O 函数 @@ -3948,7 +4326,7 @@ int main() fputs("file open error", stdout); return -1; } - fd = fdopen(fd, "w"); //返回 写 模式的 FILE 指针 + fp = fdopen(fd, "w"); //返回 写 模式的 FILE 指针 fputs("NetWork C programming \n", fp); fclose(fp); return 0; @@ -3995,7 +4373,7 @@ int main() int fd = open("data.dat", O_WRONLY | O_CREAT | O_TRUNC); if (fd == -1) { - fputs("file open error"); + fputs("file open error", stdout); return -1; } @@ -4036,7 +4414,11 @@ gcc echo_stdserv.c -o eserver 1. **请说明标准 I/O 的 2 个优点。他为何拥有这 2 个优点?** - 答:①具有很高的移植性②有良好的缓冲提高性能。因为这些函数是由 ANSI C 标准定义的。适合所有编程领域。 + 答:①具有很高的移植性②有良好的缓冲提高性能。 + + 移植性的原因:标准 I/O 函数是由 ANSI C 标准定义的,在任何符合 ANSI C 标准的平台上都能使用,适合所有编程领域。 + + 性能的原因:标准 I/O 函数内部维护了用户态缓冲区,数据首先在缓冲区中积累,当缓冲区填满或显式刷新时才一次性调用系统函数(如 `write`)进行实际 I/O。这减少了用户态与内核态之间上下文切换的次数,从而显著提高了性能。 2. **利用标准 I/O 函数传输数据时,下面的说法是错误的**: @@ -4044,8 +4426,14 @@ gcc echo_stdserv.c -o eserver **为何上述说法是错误的?为达到这种效果应该添加哪些处理过程?** - 答:只是传输到了缓冲中,应该利用 fflush 来刷新缓冲区。 + 答:因为标准 I/O 函数使用缓冲机制,调用 `fputs` 后数据只是被写入到用户态的缓冲区中,而不是立即发送到套接字输出缓冲或对端主机。只有在缓冲区满、缓冲区方向改变(如从读切换到写)、文件关闭或显式刷新时,数据才会真正发送。 + 为达到立即发送的效果,应该在调用 `fputs` 后添加 `fflush` 函数来刷新缓冲区,例如: + + ```c + fputs("Hello", fp); + fflush(fp); // 强制将缓冲区数据发送 + ``` ## 第 16 章 关于 I/O 流分离的其他内容 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -4054,7 +4442,7 @@ gcc echo_stdserv.c -o eserver 「分离 I/O 流」是一种常用表达。有 I/O 工具可区分二者,无论采用哪种方法,都可以认为是分离了 I/O 流。 -#### 16.1.1 2次 I/O 流分离 +#### 16.1.1 2 次 I/O 流分离 之前有两种分离方法: @@ -4106,7 +4494,7 @@ gcc sep_serv.c -o serv 原因是:服务端代码的 `fclose(writefp);` 这一句,完全关闭了套接字而不是半关闭。这才是这一章需要解决的问题。 -### 16.2 文件描述符的的复制和半关闭 +### 16.2 文件描述符的复制和半关闭 #### 16.2.1 终止「流」时无法半关闭原因 @@ -4124,17 +4512,17 @@ gcc sep_serv.c -o serv 只需要创建 FILE 指针前先复制文件描述符即可。复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式的 FILE 指针和写模式的 FILE 指针。这就为半关闭创造好了环境,因为套接字和文件描述符具有如下关系: -> 销毁所有文件描述符候才能销毁套接字 +> 销毁所有文件描述符后才能销毁套接字 也就是说,针对写模式 FILE 指针调用 fclose 函数时,只能销毁与该 FILE 指针相关的文件描述符,无法销毁套接字,如下图: ![](https://i.loli.net/2019/01/30/5c5123ad7df31.png) -那么调用 fclose 函数候还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O 。因此,不但没有发送 EOF ,而且仍然可以利用文件描述符进行输出。 +那么调用 fclose 函数后还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O。因此,不但没有发送 EOF,而且仍然可以利用文件描述符进行输出。 #### 16.2.2 复制文件描述符 -与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对完成描述符的复制。如图: +与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对文件描述符的复制。如图: ![](https://i.loli.net/2019/01/30/5c512579c45b6.png) @@ -4146,12 +4534,12 @@ gcc sep_serv.c -o serv ```c #include -int dup(int fildes); -int dup2(int fildes, int fildes2); +int dup(int fd); +int dup2(int fd, int fd2); /* 成功时返回复制的文件描述符,失败时返回 -1 -fildes : 需要复制的文件描述符 -fildes2 : 明确指定的文件描述符的整数值。 +fd : 需要复制的文件描述符 +fd2 : 明确指定的文件描述符的整数值 */ ``` @@ -4169,18 +4557,18 @@ int main(int argc, char *argv[]) char str1[] = "Hi~ \n"; char str2[] = "It's nice day~ \n"; - cfd1 = dup(1); //复制文件描述符 1 - cfd2 = dup2(cfd1, 7); //再次复制文件描述符,定为数值 7 + cfd1 = dup(1); // 复制文件描述符 1 + cfd2 = dup2(cfd1, 7); // 再次复制文件描述符,指定数值为 7 printf("fd1=%d , fd2=%d \n", cfd1, cfd2); write(cfd1, str1, sizeof(str1)); write(cfd2, str2, sizeof(str2)); close(cfd1); - close(cfd2); //终止复制的文件描述符,但是仍有一个文件描述符 + close(cfd2); // 终止复制的文件描述符,但是仍有一个文件描述符 write(1, str1, sizeof(str1)); close(1); - write(1, str2, sizeof(str2)); //无法完成输出 + write(1, str2, sizeof(str2)); // 无法完成输出 return 0; } @@ -4201,9 +4589,6 @@ gcc dup.c -o dup 下面更改 [sep_clnt.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_clnt.c) 和 [sep_serv.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_serv.c) 可以使得让它正常工作,正常工作是指通过服务器的半关闭状态接收客户端最后发送的字符串。 -**该例子得出结论** ->无论复制出多少文件描述符,均应调用shutdown函数发送EOF并进入半关闭状态。 - 下面是代码: - [sep_serv2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_serv2.c) @@ -4214,27 +4599,26 @@ gcc dup.c -o dup ### 16.3 习题 -> 以下答案仅代表本人个人观点,可能不是正确答案。 +> 以下答案仅代表本人个人观点,可能不是标准答案。 1. **下列关于 FILE 结构体指针和文件描述符的说法错误的是**? - 答:以下加粗内容代表说法正确。 + 答:第 1、2、5 项说法错误。 - 1. 与 FILE 结构体指针相同,文件描述符也分输入描述符和输出描述符 - 2. 复制文件描述符时将生成相同值的描述符,可以通过这 2 个描述符进行 I/O - 3. **可以利用创建套接字时返回的文件描述符进行 I/O ,也可以不通过文件描述符,直接通过 FILE 结构体指针完成** - 4. **可以从文件描述符生成 FILE 结构体指针,而且可以利用这种 FILE 结构体指针进行套接字 I/O** - 5. 若文件描述符为读模式,则基于该描述符生成的 FILE 结构体指针同样是读模式;若文件描述符为写模式,则基于该描述符生成的 FILE 结构体指针同样是写模式 + - 第 1 项错误:文件描述符不像 FILE 结构体指针那样分输入和输出模式,同一个文件描述符可以同时进行输入和输出操作。 + - 第 2 项错误:复制文件描述符时生成的是不同值的描述符,但它们指向同一个文件或套接字。 + - 第 3 项正确:可以利用创建套接字时返回的文件描述符进行 I/O,也可以通过 fdopen 函数将文件描述符转换为 FILE 结构体指针后进行 I/O。 + - 第 4 项正确:可以从文件描述符生成 FILE 结构体指针(通过 fdopen 函数),并且可以利用这种 FILE 结构体指针进行套接字 I/O。 + - 第 5 项错误:文件描述符本身没有读模式或写模式的区分,读写模式是在调用 fdopen 函数生成 FILE 结构体指针时指定的。 2. **EOF 的发送相关描述中错误的是**? - 答:以下加粗内容代表说法正确。 - - 1. 终止文件描述符时发送 EOF - 2. **即使未完全终止文件描述符,关闭输出流时也会发送 EOF** - 3. 如果复制文件描述符,则包括复制的文件描述符在内,所有文件描述符都终止时才会发送 EOF - 4. **即使复制文件描述符,也可以通过调用 shutdown 函数进入半关闭状态并发送 EOF** + 答:第 1、3 项说法错误。 + - 第 1 项错误:仅终止文件描述符(调用 close)并不一定发送 EOF,只有在所有指向该套接字的文件描述符都被关闭时才会发送 EOF。 + - 第 2 项正确:即使未终止文件描述符,调用 shutdown 函数关闭输出流时也会发送 EOF,这正是半关闭的实现方式。 + - 第 3 项错误:即使复制了文件描述符,也可以通过 shutdown 函数单独关闭输出流并发送 EOF,而不需要关闭所有文件描述符。 + - 第 4 项正确:即使复制了文件描述符,shutdown 函数仍然可以对套接字本身进行半关闭操作,不受文件描述符复制的影响。 ## 第 17 章 优于 select 的 epoll 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -4256,11 +4640,11 @@ select 性能上最大的弱点是:每次传递监视对象信息,准确的 > 仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项 -这样就无需每次调用 select 函数时都想操作系统传递监视对象信息,但是前提操作系统支持这种处理方式。Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。 +这样就无需每次调用 select 函数时都向操作系统传递监视对象信息,但是前提操作系统支持这种处理方式。Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。 #### 17.1.2 select 也有优点 -select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数满足以下两个条件: +select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,满足以下两个条件时可以使用 select 函数: - 服务器接入者少 - 程序应该具有兼容性 @@ -4320,7 +4704,7 @@ size:epoll 实例的大小 调用 epoll_create 函数时创建的文件描述符保存空间称为「epoll 例程」,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考。 -> Linux 2.6.8 之后的内核将完全传入 epoll_create 函数的 size 函数,因此内核会根据情况调整 epoll 例程大小。但是本书程序并没有忽略 size +> Linux 2.6.8 之后的内核将完全忽略传入 epoll_create 函数的 size 参数,因此内核会根据情况调整 epoll 例程大小。但是本书程序并没有忽略 size epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数 @@ -4334,7 +4718,7 @@ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); /* 成功时返回 0 ,失败时返回 -1 epfd:用于注册监视对象的 epoll 例程的文件描述符 -op:用于制定监视对象的添加、删除或更改等操作 +op:用于指定监视对象的添加、删除或更改等操作 fd:需要注册的监视对象文件描述符 event:监视对象的事件类型 */ @@ -4342,13 +4726,13 @@ event:监视对象的事件类型 与其他 epoll 函数相比,该函数看起来有些复杂,但通过调用语句就很容易理解,假设按照如下形式调用 epoll_ctl 函数: -```CQL -epoll_ctl(A,EPOLL_CTL_ADD,B,C); +```c +epoll_ctl(A, EPOLL_CTL_ADD, B, C); ``` 第二个参数 EPOLL_CTL_ADD 意味着「添加」,上述语句有如下意义: -> epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件 +> epoll 例程 A 中注册文件描述符 B,主要目的是为了监视参数 C 中的事件 再介绍一个调用语句。 @@ -4356,7 +4740,7 @@ epoll_ctl(A,EPOLL_CTL_ADD,B,C); epoll_ctl(A,EPOLL_CTL_DEL,B,NULL); ``` -上述语句中第二个参数意味这「删除」,有以下含义: +上述语句中第二个参数意味着「删除」,有以下含义: > 从 epoll 例程 A 中删除文件描述符 B @@ -4368,7 +4752,7 @@ epoll_ctl(A,EPOLL_CTL_DEL,B,NULL); - EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符 - EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况 -epoll_event 结构体用于保存事件的文件描述符集合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过调用语句说明该结构体在 epoll_ctl 函数中的应用。 +epoll_event 结构体用于保存事件的文件描述符结合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过调用语句说明该结构体在 epoll_ctl 函数中的应用。 ```c struct epoll_event event; @@ -4389,7 +4773,7 @@ epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event); - EPOLLET:以边缘触发的方式得到事件通知 - EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。 -可通过位运算同时传递多个上述参数。 +可通过位或运算同时传递多个上述参数。 #### 17.1.6 epoll_wait @@ -4399,7 +4783,7 @@ epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event); #include int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); /* -成功时返回发生事件的文件描述符,失败时返回 -1 +成功时返回发生事件的文件描述符个数,失败时返回 -1 epfd : 表示事件发生监视范围的 epoll 例程的文件描述符 events : 保存发生事件的文件描述符集合的结构体地址值 maxevents : 第二个参数中可以保存的最大事件数 @@ -4419,7 +4803,7 @@ event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1); ... ``` -调用函数后,返回发生事件的文件描述符,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。 +调用函数后,返回发生事件的文件描述符个数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。 #### 17.1.7 基于 epoll 的回声服务器端 @@ -4444,7 +4828,7 @@ gcc echo_epollserv.c -o serv 总结一下 epoll 的流程: -1. epoll_create 创建一个保存 epoll 文件描述符的空间,可以没有参数 +1. epoll_create 创建一个保存 epoll 文件描述符的空间(size 参数仅作为建议传递) 2. 动态分配内存,给将要监视的 epoll_wait 3. 利用 epoll_ctl 控制 添加 删除,监听事件 4. 利用 epoll_wait 来获取改变的文件描述符,来执行程序 @@ -4489,7 +4873,7 @@ gcc echo_EPLTserv.c -o serv ![](https://i.loli.net/2019/02/01/5c540825ae415.png) -从结果可以看出,每当收到客户端数据时,都回注册该事件,并因此调用 epoll_wait 函数。 +从结果可以看出,每当收到客户端数据时,都会注册该事件,并因此调用 epoll_wait 函数。 下面的代码是修改后的边缘触发方式的代码,仅仅是把上面的代码改为: @@ -4527,7 +4911,7 @@ Linux 套接字相关函数一般通过 -1 通知发生了错误。虽然知道 int errno; ``` -为了访问该变量,需要引入 `error.h` 头文件,因此此头文件有上述变量的 extren 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。 +为了访问该变量,需要引入 `errno.h` 头文件,因此此头文件有上述变量的 extern 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。 > read 函数发现输入缓冲中没有数据可读时返回 -1,同时在 errno 中保存 EAGAIN 常量 @@ -4535,7 +4919,7 @@ int errno; ```c #include -int fcntl(int fields, int cmd, ...); +int fcntl(int filedes, int cmd, ...); /* 成功时返回 cmd 参数相关值,失败时返回 -1 filedes : 属性更改目标的文件描述符 @@ -4543,14 +4927,14 @@ cmd : 表示函数调用目的 */ ``` -从上述声明可以看出 fcntl 有可变参数的形式。如果向第二个参数传递 F_GETFL ,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL ,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下 2 条语句。 +从上述声明可以看出 fcntl 有可变参数的形式。如果向第二个参数传递 F_GETFL ,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL ,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下 2 条语句。 -```C -int flag = fcntl(fd,F_GETFL,0); -fcntl(fd,F_SETFL | O_NONBLOCK) +```c +int flag = fcntl(fd, F_GETFL, 0); +fcntl(fd, F_SETFL, flag | O_NONBLOCK); ``` -通过第一条语句,获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read/write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。fcntl 函数的适用范围很广。 +通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read/write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。fcntl 函数的适用范围很广。 #### 17.2.4 实现边缘触发回声服务器端 @@ -4583,25 +4967,25 @@ gcc echo_EPETserv.c -o serv > 可以分离接收数据和处理数据的时间点! -下面是边缘触发的图 +下面是边缘触发的图: ![](https://i.loli.net/2019/02/01/5c5421e3b3f2b.png) 运行流程如下: -- 服务器端分别从 A B C 接收数据 -- 服务器端按照 A B C 的顺序重新组合接收到的数据 +- 服务器端分别从 A、B、C 接收数据 +- 服务器端按照 A、B、C 的顺序重新组合接收到的数据 - 组合的数据将发送给任意主机。 为了完成这个过程,如果可以按照如下流程运行,服务端的实现并不难: -- 客户端按照 A B C 的顺序连接服务器,并且按照次序向服务器发送数据 -- 需要接收数据的客户端应在客户端 A B C 之前连接到服务器端并等待 +- 客户端按照 A、B、C 的顺序连接服务器,并且按照次序向服务器发送数据 +- 需要接收数据的客户端应在客户端 A、B、C 之前连接到服务器端并等待 但是实际情况中可能是下面这样: - 客户端 C 和 B 正在向服务器发送数据,但是 A 并没有连接到服务器 -- 客户端 A B C 乱序发送数据 +- 客户端 A、B、C 乱序发送数据 - 服务端已经接收到数据,但是要接收数据的目标客户端并没有连接到服务器端。 因此,即使输入缓冲收到数据,服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来很大灵活性。 @@ -4633,7 +5017,6 @@ gcc echo_EPETserv.c -o serv 6. 采用边缘触发时可以分离数据的接收和处理时间点。请说明其优点和原因。 答:分离接收数据和处理数据的时间点,给服务端的实现带来很大灵活性。 - ## 第 18 章 多线程服务器端的实现 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -4651,7 +5034,7 @@ gcc echo_EPETserv.c -o serv - 每秒少则 10 次,多则千次的「上下文切换」是创建进程的最大开销 -只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息。这就是上下文切换。但是此时进程 A 的数据将被移动到硬盘,所以上下文切换要很长时间,即使通过优化加快速度,也会存在一定的局限。 +只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存(或保存到寄存器),并读入进程 B 相关信息。这就是上下文切换。上下文切换需要保存和恢复进程的上下文信息(寄存器、程序计数器、栈指针等),这个过程会带来一定的开销,即使通过优化加快速度,也会存在一定的局限。 为了保持多进程的优点,同时在一定程度上克服其缺点,人们引入的线程(Thread)的概念。这是为了将进程的各种劣势降至最低程度(不是直接消除)而设立的一种「轻量级进程」。线程比进程具有如下优点: @@ -4706,12 +5089,11 @@ int pthread_create(pthread_t *restrict thread, void *(*start_routine)(void *), void *restrict arg); /* -成功时返回 0 ,失败时返回 -1 +成功时返回 0 ,失败时返回错误码(正数) thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程 start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针) arg : 通过第三个参数传递的调用函数时包含传递参数信息的变量地址值 - //传递参数变量的地址给start_routine函数 */ ``` @@ -4775,13 +5157,13 @@ gcc thread1.c -o tr1 -lpthread # 线程相关代码编译时需要添加 -lpthre #include int pthread_join(pthread_t thread, void **status); /* -成功时返回 0 ,失败时返回 -1 +成功时返回 0 ,失败时返回错误码(正数) thread : 该参数值 ID 的线程终止后才会从该函数返回 -status : 保存线程的 main 函数返回值的指针变量地址值 +status : 保存线程的 main 函数返回值的指针的变量地址值 */ ``` -作用就是调用该函数的进程(或线程)将进入等待状态,知道第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。下面是该函数的用法代码: +作用就是调用该函数的进程(或线程)将进入等待状态,直到第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。下面是该函数的用法代码: - [thread2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch18/thread2.c) @@ -4827,8 +5209,6 @@ void *thread_main(void *arg) //传入的参数是 pthread_create 的第四个 return (void *)msg; //返回值是 thread_main 函数中内部动态分配的内存空间地址值 } ``` -**关于`void *msg`和`void *thr_ret`** ->将`pointer thr_ret`的地址改成`pointer msg`的地址,即 `*thr_ret`获得的是`msg`指向的数据,即`*msg`。也可以看作引用。 编译运行: @@ -4876,7 +5256,7 @@ struct hostent *gethostbyname(const char *hostname); struct hostent *gethostbyname_r(const char *name, struct hostent *result, char *buffer, - int intbuflen, + size_t buflen, int *h_errnop); ``` @@ -4884,7 +5264,7 @@ struct hostent *gethostbyname_r(const char *name, > 声明头文件前定义 `_REENTRANT` 宏。 -无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。 +无需特意更改源代码,可以在编译的时候指定编译参数定义宏。 ```shell gcc -D_REENTRANT mythread.c -o mthread -lpthread @@ -5023,7 +5403,7 @@ gcc thread4.c -D_REENTRANT -o tr4 -lpthread 任何内存空间,只要被同时访问,都有可能发生问题。 -因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 运算完成。这就是同步(Synchronization) +因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 运算完成。这就是同步(Synchronization)。 #### 18.3.2 临界区位置 @@ -5031,7 +5411,7 @@ gcc thread4.c -D_REENTRANT -o tr4 -lpthread > 函数内同时运行多个线程时引发问题的多条语句构成的代码块 -全局变量 num 不能视为临界区,因为他不是引起问题的语句,只是一个内存区域的声明。下面是刚才代码的两个 main 函数 +全局变量 num 不能视为临界区,因为它不是引起问题的语句,只是一个内存区域的声明。下面是刚才代码的两个线程函数 ```c void *thread_inc(void *arg) @@ -5077,9 +5457,9 @@ void *thread_des(void *arg) #### 18.4.2 互斥量 -互斥锁(英语:英语:Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全域变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域,但是并不一定会应用互斥锁。 +互斥锁(英语:Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全域变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域,但是并不一定会应用互斥锁。 -通俗的说就互斥量就是一把优秀的锁,当临界区被占据的时候就上锁,等占用完毕然后再放开。 +通俗地说,互斥量就是一把锁,当临界区被占据的时候就上锁,等占用完毕然后再放开。 下面是互斥量的创建及销毁函数。 @@ -5089,7 +5469,7 @@ int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); int pthread_mutex_destroy(pthread_mutex_t *mutex); /* -成功时返回 0,失败时返回其他值 +成功时返回 0,失败时返回错误码 mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址 attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL */ @@ -5116,11 +5496,11 @@ pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0 ,失败时返回错误码 */ ``` -函数本身含有 lock unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock 。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。接下来整理一下代码的编写方式: +函数本身含有 lock unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。接下来整理一下代码的编写方式: ```c pthread_mutex_lock(&mutex); @@ -5235,38 +5615,38 @@ semaphore对象适用于控制一个仅支持有限个用户的共享资源, int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_destroy(sem_t *sem); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0 ,失败时返回错误码 sem : 创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值 -pshared : 传递其他值时,创建可由多个进程共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为0 +pshared : 传递非 0 值时,创建可由多个进程共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为 0 value : 指定创建信号量的初始值 */ ``` -上述的 shared 参数超出了我们的关注范围,故默认向其传递为 0 。下面是信号量中相当于互斥量 lock unlock 的函数。 +上述的 pshared 参数超出我们的关注范围,故默认向其传递 0。下面是信号量中相当于互斥量 lock unlock 的函数。 ```c #include int sem_post(sem_t *sem); int sem_wait(sem_t *sem); /* -成功时返回 0 ,失败时返回其他值 -sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一 +成功时返回 0 ,失败时返回错误码 +sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增 1,传递给 sem_wait 时信号量减 1 */ ``` -调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1) +调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录着「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1,调用 sem_wait 函数时减 1。但信号量的值不能小于 0,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1): ```c -sem_wait(&sem);//信号量变为0... +sem_wait(&sem);//信号量变为 0... // 临界区的开始 //... //临界区的结束 -sem_post(&sem);//信号量变为1... +sem_post(&sem);//信号量变为 1... ``` -上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。接下来的代码是信号量机制的代码。下面代码并非是同时访问的同步,而是关于控制访问顺序的同步,该场景为: +上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。接下来的代码是信号量机制的代码。下面代码并非是同时访问的同步,而是关于控制访问顺序的同步,该场景为: -> 线程 A 从用户输入得到值后存入全局变量 num ,此时线程 B 将取走该值并累加。该过程一共进行 5 次,完成后输出总和并退出程序。 +> 线程 A 从用户输入得到值后存入全局变量 num,此时线程 B 将取走该值并累加。该过程一共进行 5 次,完成后输出总和并退出程序。 下面是代码: @@ -5338,32 +5718,34 @@ gcc semaphore.c -D_REENTRANT -o sema -lpthread ![](https://i.loli.net/2019/02/03/5c568c2717d1e.png) -从上述代码可以看出,设置了两个信号量 one 的初始值为 0 ,two 的初始值为 1,然后在调用函数的时候,「读」的前提是 two 可以减一,如果不能减一就会阻塞在这里,一直等到「计算」操作完毕后,给 two 加一,然后就可以继续执行下一句输入。对于「计算」函数,也一样。 +从上述代码可以看出,设置了两个信号量:sem_one 的初始值为 0,sem_two 的初始值为 1,然后在调用函数的时候,「读」的前提是 sem_two 可以减 1,如果不能减 1 就会阻塞在这里,一直等到「计算」操作完毕后,给 sem_two 加 1,然后就可以继续执行下一句输入。对于「计算」函数,也一样。 ### 18.5 线程的销毁和多线程并发服务器端的实现 先介绍线程的销毁,然后再介绍多线程服务端 -#### 18.5.1 销毁线程的 3 种方法 +#### 18.5.1 销毁线程的方法 -Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。 +Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以需要利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。 - 调用 pthread_join 函数 - 调用 pthread_detach 函数 -之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。 +之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,可通过如下函数调用引导线程销毁。 ```c #include -int pthread_detach(pthread_t th); +int pthread_detach(pthread_t thread); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0,失败时返回错误码 thread : 终止的同时需要销毁的线程 ID */ ``` 调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。 +> 补充说明:Linux 线程有两种状态——「可结合」(joinable)和「分离」(detached)。默认创建的线程是可结合状态,必须被 pthread_join 或 pthread_detach 才能释放其资源。如果线程被设置为分离状态,则线程结束时系统会自动回收其资源,无需其他线程调用 pthread_join。 + #### 18.5.2 多线程并发服务器端的实现 下面是多个客户端之间可以交换信息的简单聊天程序。 @@ -5396,7 +5778,7 @@ gcc chat_clnt.c -D_REENTRANT -o cclnt -lpthread 1. **单 CPU 系统中如何同时执行多个进程?请解释该过程中发生的上下文切换**。 - 答:系统将 CPU 时间分成多个微笑的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」过程。运行程序前需要将相应进程信息读入内存,如果运行进程 A 后需要紧接着运行进程 B ,就应该将进程 A 相关今夕移出内存,并读入进程 B 的信息。这就是上下文切换 + 答:系统将 CPU 时间分成多个微小的时间块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」过程。运行程序前需要将相应进程信息读入内存,如果运行进程 A 后需要紧接着运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 的信息。这就是上下文切换 2. **为何线程的上下文切换速度相对更快?线程间数据交换为何不需要类似 IPC 特别技术**。 @@ -5408,46 +5790,30 @@ gcc chat_clnt.c -D_REENTRANT -o cclnt -lpthread 4. **下面关于临界区的说法错误的是**? - 答:下面加粗的选项为说法正确。(全错) + 答:这四个选项的说法都是错误的。正确理解如下: - 1. 临界区是多个线程同时访问时发生问题的区域 - 2. 线程安全的函数中不存在临界区,即便多个线程同时调用也不会发生问题 - 3. 1 个临界区只能由 1 个代码块,而非多个代码块构成。换言之,线程 A 执行的代码块 A 和线程 B 执行的代码块 B 之间绝对不会构成临界区。 - 4. 临界区由访问全局变量的代码构成。其他变量中不会发生问题。 + 1. (错误)临界区不是指「区域」,而是指访问共享资源的代码片段。即使多个线程同时访问某些区域,如果通过同步机制保护,也不会发生问题。 + 2. (错误)线程安全的函数中同样可能存在临界区,只是通过锁等机制确保了多线程同时调用时的安全性。 + 3. (错误)临界区可以由多个代码块构成。线程 A 执行的代码块 A 和线程 B 执行的代码块 B,如果它们访问同一共享资源,就可能构成临界区。 + 4. (错误)临界区不仅由访问全局变量的代码构成,任何访问共享资源(如静态变量、堆内存、文件等)的代码都可能构成临界区。 5. **下列关于线程同步的说法错误的是**? - 答:下面加粗的选项为说法正确。 + 答:第 1 和第 4 个说法是错误的,第 2 和第 3 个说法是正确的。 - 1. 线程同步就是限制访问临界区 - 2. **线程同步也具有控制线程执行顺序的含义** - 3. **互斥量和信号量是典型的同步技术** - 4. 线程同步是代替进程 IPC 的技术。 + 1. (错误)线程同步不仅是限制访问临界区,还包括控制线程执行顺序。 + 2. (正确)线程同步也具有控制线程执行顺序的含义。 + 3. (正确)互斥量和信号量是典型的同步技术。 + 4. (错误)线程同步是解决多线程并发问题的技术,而 IPC 是解决进程间通信问题的技术,两者并不互相代替。线程间数据交换无需特殊技术是因为线程共享同一进程的内存空间,而非线程同步代替了 IPC。 6. **请说明完全销毁 Linux 线程的 2 种办法** - 答:①调用 pthread_join 函数②调用 pthread_detach 函数。第一个会阻塞调用的线程,而第二个不阻塞。都可以引导线程销毁。 + 答: -## 第 19 章 Windows 平台下线程的使用 - - - -## 第 20 章 Windows 中的线程同步 - -暂略 - -## 第 21 章 异步通知 I/O 模型 - -暂略 - -## 第 22 章 重叠 I/O 模型 - -暂略 - -## 第 23 章 IOCP - -暂略 + - **调用 pthread_join 函数**:该函数会阻塞调用线程,直到目标线程终止,然后回收其资源。适用于需要获取线程返回值的场景。 + - **调用 pthread_detach 函数**:该函数将线程设置为分离状态,不会阻塞调用线程。分离状态的线程终止时,系统会自动回收其资源。适用于不关心线程返回值的场景。 + 注意:一个线程要么被 pthread_join,要么被 pthread_detach,不能两者都调用。未被 join 或 detach 的线程终止后其资源不会被释放,会造成资源泄漏。 ## 第 24 章 制作 HTTP 服务器端 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 @@ -5466,10 +5832,7 @@ web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客 ![](https://i.loli.net/2019/02/07/5c5bc6973a4d0.png) -从上图可以看出,服务器端相应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称「无状态的 Stateless 协议」 - -**Cookie & Session** ->为了弥补HTTP无法保持连接的缺点,Web编程中通常会使用Cookie和Session技术。相信各位都接触过购物网站的购物车功能,即使关闭浏览器也不会丢失购物车内的信息(甚至不用登录)。这种保持状态的功能都是通过Cookie和 Session技术实现的。 +从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称「无状态的 Stateless 协议」。 #### 24.1.3 请求消息(Request Message)的结构 @@ -5477,17 +5840,17 @@ web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客 ![](https://i.loli.net/2019/02/07/5c5bcbb75202f.png) -从图中可以看出,请求消息可以分为请求头、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。为了降低复杂度,我们实现只能响应 GET 请求的 Web 服务器端,下面解释图中的请求行信息。其中「GET/index.html HTTP/1.1」 具有如下含义: +从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。为了降低复杂度,我们实现只能响应 GET 请求的 Web 服务器端,下面解释图中的请求行信息。其中「GET/index.html HTTP/1.1」 具有如下含义: > 请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。 -请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。 +请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。 -请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。最后的消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。但是我们的目标是实现 GET 方式的服务器端,所以可以忽略这部分内容。另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题 +请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。最后的消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。但是我们的目标是实现 GET 方式的服务器端,所以可以忽略这部分内容。另外,消息体和消息头之间以空行隔开,因此不会发生边界问题。 #### 24.1.4 响应消息(Response Message)的结构 -下面是 Web 服务器端向客户端传递的响应信息的结构。从图中可以看出,该响应消息由状态行、头信息、消息体等 3 个部分组成。状态行中有关于请求的状态信息,这是与请求消息相比最为显著地区别。 +下面是 Web 服务器端向客户端传递的响应信息的结构。从图中可以看出,该响应消息由状态行、头信息、消息体等 3 个部分组成。状态行中有关于请求的状态信息,这是与请求消息相比最为显著的区别。 ![](https://i.loli.net/2019/02/07/5c5bf9ad1b5f9.png) @@ -5626,7 +5989,7 @@ void send_data(FILE *fp, char *ct, char *file_name) fputs(cnt_len, fp); fputs(cnt_type, fp); - //传输请求数据 + //传输响应体数据 while (fgets(buf, BUF_SIZE, send_file) != NULL) { fputs(buf, fp); @@ -5692,28 +6055,19 @@ gcc webserv_linux.c -D_REENTRANT -o web_serv -lpthread 1. **下列关于 Web 服务器端和 Web 浏览器端的说法错误的是**? - 答:以下加粗选项代表正确。 + 答:**选项 5 是错误的**。 - 1. **Web 浏览器并不是通过自身创建的套接字连接服务端的客户端** + 1. Web 浏览器是通过自身创建的套接字连接服务端的客户端 2. Web 服务器端通过 TCP 套接字提供服务,因为它将保持较长的客户端连接并交换数据 3. 超文本与普通文本的最大区别是其具有可跳转的特性 4. Web 浏览器可视为向浏览器提供请求文件的文件传输服务器端 - 5. 除 Web 浏览器外,其他客户端都无法访问 Web 服务器端。 + 5. **除 Web 浏览器外,其他客户端都无法访问 Web 服务器端。(错误:任何能发起 HTTP 请求的客户端都可以访问 Web 服务器端,如 curl、wget、编程实现的 HTTP 客户端等)** 2. **下列关于 HTTP 协议的描述错误的是**? - 答:以下加粗选项代表正确。 - - 1. HTTP 协议是无状态的 Stateless 协议,不仅可以通过 TCP 实现,还可以通过 UDP 来实现 - 2. **HTTP 协议是无状态的 Stateless 协议,因为其在 1 次请求和响应过程完成后立即断开连接。因此,如果同一服务器端和客户端需要 3 次请求及响应,则意味着需要经过 3 次套接字的创建过程**。 - 3. **服务端向客户端传递的状态码中含有请求处理结果的信息**。 - 4. **HTTP 协议是基于因特网的协议,因此,为了同时向大量客户端提供服务,HTTP 协议被设计为 Stateless 协议**。 - -**我的笔记到此结束** :grin: - -## :memo:License - -本仓库遵循 CC BY-NC-SA 4.0(署名 - 非商业性使用) 协议,转载请注明出处。 - -[![CC BY-NC-SA 4.0](https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png)](http://creativecommons.org/licenses/by-nc-sa/4.0/) + 答:**选项 1 是错误的**。 + 1. **HTTP 协议是无状态的 Stateless 协议,不仅可以通过 TCP 实现,还可以通过 UDP 来实现。(错误:标准 HTTP 协议是基于 TCP 的,HTTP/3 基于 QUIC 协议即 UDP 实现,但传统意义上 HTTP 是基于 TCP 的)** + 2. HTTP 协议是无状态的 Stateless 协议,因为其在 1 次请求和响应过程完成后立即断开连接。因此,如果同一服务器端和客户端需要 3 次请求及响应,则意味着需要经过 3 次套接字的创建过程。 + 3. 服务端向客户端传递的状态码中含有请求处理结果的信息。 + 4. HTTP 协议是基于因特网的协议,因此,为了同时向大量客户端提供服务,HTTP 协议被设计为 Stateless 协议。 diff --git a/ch01/README.md b/ch01/README.md index 3080d1f..282ba01 100644 --- a/ch01/README.md +++ b/ch01/README.md @@ -1,6 +1,6 @@ ## 第一章:理解网络编程和套接字 -本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到,直接点连接可能进不去。 +本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到,直接点链接可能进不去。 ### 1.1 理解网络编程和套接字 @@ -28,7 +28,7 @@ int socket(int domain, int type, int protocol); > > 答:我的电话号码是123-1234 -套接字同样如此。就想给电话机分配电话号码一样,利用以下函数给创建好的套接字分配地址信息(IP地址和端口号): +套接字同样如此。就像给电话机分配电话号码一样,利用以下函数给创建好的套接字分配地址信息(IP地址和端口号): ```c #include @@ -79,7 +79,7 @@ int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 服务器端(server)是能够受理连接请求的程序。下面构建服务端以验证之前提到的函数调用过程,该服务器端收到连接请求后向请求者返回`Hello World!`答复。除各种函数的调用顺序外,我们还未涉及任何实际编程。因此,阅读代码时请重点关注套接字相关的函数调用过程,不必理解全过程。 -服务器端代码请参见:[hello_server.c](ch01/hello_server.c) +服务器端代码请参见:[hello_server.c](hello_server.c) **客户端**: @@ -88,7 +88,7 @@ int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen); 1. 调用 socket 函数 和 connect 函数 2. 与服务端共同运行以收发字符串数据 -客户端代码请参见:[hello_client.c](ch01/hello_client.c) +客户端代码请参见:[hello_client.c](hello_client.c) **编译**: @@ -106,7 +106,7 @@ gcc hello_client.c -o hclient ./hclient 127.0.0.1 9190 ``` -运行的时候,首先再 9190 端口启动服务,然后 heserver 就会一直等待客户端进行响应,当客户端监听位于本地的 IP 为 127.0.0.1 的地址的9190端口时,客户端就会收到服务端的回应,输出`Hello World!` +运行的时候,首先在 9190 端口启动服务,然后 hserver 就会一直等待客户端进行连接,当客户端连接位于本地的 IP 为 127.0.0.1 的地址的 9190 端口时,客户端就会收到服务端的回应,输出`Hello World!` ### 1.2 基于 Linux 的文件操作 @@ -124,7 +124,7 @@ gcc hello_client.c -o hclient 文件和套接字一般经过创建过程才会被分配文件描述符。 -文件描述符也被称为「文件句柄」,但是「句柄」主要是 Windows 中的术语。因此,在本书中如果设计 Windows 平台将使用「句柄」,如果是 Linux 将使用「描述符」。 +文件描述符也被称为「文件句柄」,但是「句柄」主要是 Windows 中的术语。因此,在本书中如果涉及 Windows 平台将使用「句柄」,如果是 Linux 将使用「描述符」。 #### 1.2.2 打开文件: @@ -162,7 +162,7 @@ fd : 需要关闭的文件或套接字的文件描述符 */ ``` -若调用此函数同时传递文件描述符参数,则关闭(终止)响应文件。另外需要注意的是,此函数不仅可以关闭文件,还可以关闭套接字。再次证明了「Linux 操作系统不区分文件与套接字」的特点。 +若调用此函数同时传递文件描述符参数,则关闭(终止)相应文件。另外需要注意的是,此函数不仅可以关闭文件,还可以关闭套接字。再次证明了「Linux 操作系统不区分文件与套接字」的特点。 #### 1.2.4 将数据写入文件: @@ -177,11 +177,11 @@ nbytes : 要传输数据的字节数 */ ``` -在此函数的定义中,size_t 是通过 typedef 声明的 unsigned int 类型。对 ssize_t 来说,ssize_t 前面多加的 s 代表 signed ,即 ssize_t 是通过 typedef 声明的 signed int 类型。 +在此函数的定义中,size_t 是通过 typedef 声明的无符号整型(通常为 `unsigned long`)。对 ssize_t 来说,ssize_t 前面多加的 s 代表 signed(有符号),即 ssize_t 是通过 typedef 声明的有符号整型(通常为 `signed long`)。 创建新文件并保存数据: -代码见:[low_open.c](ch01/low_open.c) +代码见:[low_open.c](low_open.c) 编译运行: @@ -209,7 +209,7 @@ nbytes : 要接收数据的最大字节数 下面示例通过 read() 函数读取 data.txt 中保存的数据。 -代码见:[low_read.c](ch01/low_read.c) +代码见:[low_read.c](low_read.c) 编译运行: @@ -218,7 +218,7 @@ gcc low_read.c -o lread ./lread ``` -在上一步的 data.txt 文件与没有删的情况下,会输出: +在上一步的 data.txt 文件如果没有删的情况下,会输出: ``` file descriptor: 3 @@ -231,7 +231,7 @@ file data: Let's go! 下面将同时创建文件和套接字,并用整数型态比较返回的文件描述符的值. -代码见:[fd_seri.c](ch01/fd_seri.c) +代码见:[fd_seri.c](fd_seri.c) **编译运行**: @@ -270,7 +270,7 @@ file descriptor 3: 16 3. Linux 中,对套接字数据进行 I/O 时可以直接使用文件 I/O 相关函数;而在 Windows 中则不可以。原因为何? - > 答:暂略。 + > 答:在 Linux 中,套接字(socket)被视为文件的一种,遵循「一切皆文件」的设计哲学。因此可以使用标准的文件 I/O 函数(如 `read`、`write`、`close`)对套接字进行操作。而在 Windows 中,套接字和文件是区分开的,套接字操作需要使用专门的 Winsock 函数(如 `send`、`recv`、`closesocket`),不能使用文件 I/O 函数(如 `ReadFile`、`WriteFile`)直接操作套接字。 4. 创建套接字后一般会给他分配地址,为什么?为了完成地址分配需要调用哪个函数? @@ -278,7 +278,7 @@ file descriptor 3: 16 5. Linux 中的文件描述符与 Windows 的句柄实际上非常类似。请以套接字为对象说明它们的含义。 - > 答:暂略。 + > 答:文件描述符(File Descriptor)是 Linux 内核为了高效管理已被打开的文件所创建的索引,用于标识打开的文件、套接字等 I/O 资源,是一个非负整数。句柄(Handle)是 Windows 中用于标识资源(如文件、套接字、窗口等)的抽象引用,本质上是一个指针或索引。两者的相似之处在于:它们都是操作系统用来标识和访问资源的标识符;区别在于:Linux 的文件描述符是整数值,且遵循「一切皆文件」原则,套接字可以使用文件 I/O 函数操作;而 Windows 的句柄是 opaque 类型(不透明的指针类型),且需要针对不同资源类型使用专门的 API 函数。 6. 底层 I/O 函数与 ANSI 标准定义的文件 I/O 函数有何区别? @@ -286,4 +286,101 @@ file descriptor 3: 16 7. 参考本书给出的示例`low_open.c`和`low_read.c`,分别利用底层文件 I/O 和 ANSI 标准 I/O 编写文件复制程序。可任意指定复制程序的使用方法。 - > 答:暂略。 + > 答: + > + > **使用底层文件 I/O(Linux 系统调用)的文件复制程序:** + > + > ```c + > #include + > #include + > #include + > #include + > #include + > + > #define BUF_SIZE 4096 + > + > int main(int argc, char *argv[]) { + > if (argc != 3) { + > printf("Usage: %s \n", argv[0]); + > exit(1); + > } + > + > int src_fd = open(argv[1], O_RDONLY); + > if (src_fd == -1) { + > perror("open source file failed"); + > exit(1); + > } + > + > int dest_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); + > if (dest_fd == -1) { + > perror("open dest file failed"); + > close(src_fd); + > exit(1); + > } + > + > char buf[BUF_SIZE]; + > ssize_t read_cnt; + > + > while ((read_cnt = read(src_fd, buf, BUF_SIZE)) > 0) { + > write(dest_fd, buf, read_cnt); + > } + > + > close(src_fd); + > close(dest_fd); + > + > return 0; + > } + > ``` + > + > **使用 ANSI 标准 I/O(标准 C 库)的文件复制程序:** + > + > ```c + > #include + > #include + > + > #define BUF_SIZE 4096 + > + > int main(int argc, char *argv[]) { + > if (argc != 3) { + > printf("Usage: %s \n", argv[0]); + > exit(1); + > } + > + > FILE *src_fp = fopen(argv[1], "rb"); + > if (src_fp == NULL) { + > perror("fopen source file failed"); + > exit(1); + > } + > + > FILE *dest_fp = fopen(argv[2], "wb"); + > if (dest_fp == NULL) { + > perror("fopen dest file failed"); + > fclose(src_fp); + > exit(1); + > } + > + > char buf[BUF_SIZE]; + > size_t read_cnt; + > + > while ((read_cnt = fread(buf, 1, BUF_SIZE, src_fp)) > 0) { + > fwrite(buf, 1, read_cnt, dest_fp); + > } + > + > fclose(src_fp); + > fclose(dest_fp); + > + > return 0; + > } + > ``` + > + > **编译运行示例:** + > + > ```shell + > # 底层 I/O 版本 + > gcc file_copy_low.c -o fcopy_low + > ./fcopy_low source.txt dest.txt + > + > # 标准 I/O 版本 + > gcc file_copy_stdio.c -o fcopy_stdio + > ./fcopy_stdio source.txt dest.txt + > ``` diff --git a/ch02/README.md b/ch02/README.md index bb28105..2da044c 100644 --- a/ch02/README.md +++ b/ch02/README.md @@ -28,13 +28,13 @@ protocol: 计算机间通信中使用的协议信息 | 名称 | 协议族 | | --------- | -------------------- | -| PF_INET | IPV4 互联网协议族 | -| PF_INET6 | IPV6 互联网协议族 | +| PF_INET | IPv4 互联网协议族 | +| PF_INET6 | IPv6 互联网协议族 | | PF_LOCAL | 本地通信 Unix 协议族 | | PF_PACKET | 底层套接字的协议族 | -| PF_IPX | IPX Novel 协议族 | +| PF_IPX | IPX Novell 协议族 | -本书着重讲 PF_INET 对应的 IPV4 互联网协议族。其他协议并不常用,或并未普及。**另外,套接字中采用的最终的协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。** +本书着重讲 PF_INET 对应的 IPv4 互联网协议族。其他协议并不常用,或并未普及。**另外,套接字中采用的最终的协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。** #### 2.1.3 套接字类型(Type) @@ -83,7 +83,7 @@ socket 函数的第三个参数决定最终采用的协议。前面已经通过 > 可以应对同一协议族中存在的多个数据传输方式相同的协议,所以数据传输方式相同,但是协议不同,需要用第三个参数指定具体的协议信息。 -本书用的是 Ipv4 的协议族,和面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP ,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。 +本书用的是 IPv4 的协议族,和面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP ,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。 ```c int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); @@ -127,7 +127,99 @@ Function read call count: 13 ### 2.2 Windows 平台下的实现及验证 -暂略 +在 Windows 平台下使用套接字需要进行一些初始化工作,这与 Linux 平台有所不同。 + +#### 2.2.1 Winsock 初始化 + +Windows 下的套接字称为 Winsock,使用前必须调用 `WSAStartup` 函数进行初始化: + +```c +#include +int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData); +/* +成功时返回0,失败时返回非0错误代码 +wVersionRequested: 程序员请求的Winsock版本,高字节指副版本,低字节指主版本 +lpWSAData: 指向WSADATA结构的指针,用于接收Windows Sockets实现的详细信息 +*/ +``` + +使用 Winsock 完成后,应调用 `WSACleanup` 函数释放资源: + +```c +int WSACleanup(void); +/* +成功时返回0,失败时返回SOCKET_ERROR +*/ +``` + +#### 2.2.2 Windows 与 Linux 套接字编程的主要区别 + +| 特性 | Linux | Windows | +| ---- | ----- | ------- | +| 头文件 | `sys/socket.h` 等 | `winsock2.h`, `ws2tcpip.h` | +| 初始化 | 无需初始化 | 必须调用 `WSAStartup` | +| 套接字类型 | 文件描述符(int) | `SOCKET` 类型(实际是 `UINT_PTR`) | +| 错误检查 | 返回-1表示失败 | 返回 `INVALID_SOCKET` 表示失败 | +| 关闭套接字 | `close(fd)` | `closesocket(socket)` | +| 获取错误码 | 访问全局变量 `errno` | 调用 `WSAGetLastError()` | +| I/O 函数 | `read`, `write` 可用于套接字 | `recv`, `send` 必须用于套接字 | + +#### 2.2.3 Windows 平台代码示例 + +Windows 平台下的 TCP 客户端和服务端代码结构类似,主要区别在于初始化和清理过程。以下是 Windows 版本的基本结构: + +```c +#include +#include + +#pragma comment(lib, "ws2_32.lib") // 链接 Winsock 库 + +int main(int argc, char *argv[]) +{ + WSADATA wsaData; + SOCKET hServSock, hClntSock; + SOCKADDR_IN servAddr, clntAddr; + int szClntAddr; + + // 1. 初始化 Winsock + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + printf("WSAStartup() error"); + return 1; + } + + // 2. 创建套接字 + hServSock = socket(PF_INET, SOCK_STREAM, 0); + if (hServSock == INVALID_SOCKET) { + printf("socket() error"); + WSACleanup(); + return 1; + } + + // ... 其余代码与 Linux 版本类似 ... + + // 9. 关闭套接字 + closesocket(hServSock); + + // 10. 清理 Winsock + WSACleanup(); + + return 0; +} +``` + +编译命令(使用 Visual Studio 的命令行工具): + +```cmd +cl tcp_client_win.c /link ws2_32.lib +cl tcp_server_win.c /link ws2_32.lib +``` + +或者使用 MinGW: + +```cmd +gcc tcp_client_win.c -o hclient -lws2_32 +gcc tcp_server_win.c -o hserver -lws2_32 +``` ### 2.3 习题 @@ -155,4 +247,7 @@ Function read call count: 13 5. 何种类型的套接字不存在数据边界?这类套接字接收数据时应该注意什么? - > 答:TCP 不存在数据边界。在接收数据时,需要保证在接收套接字的缓冲区填充满之时就从buffer里读取数据。也就是,在接收套接字内部,写入buffer的速度要小于读出buffer的速度。 + > 答:面向连接的 TCP 套接字不存在数据边界。接收数据时需要注意: + > 1. **数据可能分多次到达**:发送方调用一次 write 发送 100 字节,接收方可能需要多次 read 才能读完,或者一次 read 就能读完多次 write 的数据。 + > 2. **需要定义应用层协议**:由于没有边界,必须在应用层定义数据边界(如固定长度、分隔符、长度前缀等方式),否则无法正确解析数据。 + > 3. **缓冲区管理**:虽然 TCP 内部有流量控制机制保证不丢数据,但应用层仍应及时读取数据,避免接收缓冲区占用过多内存。 diff --git a/ch03/README.md b/ch03/README.md index c23e65e..829f639 100644 --- a/ch03/README.md +++ b/ch03/README.md @@ -12,12 +12,12 @@ IP 是 Internet Protocol(网络协议)的简写,是为收发网络数据 为使计算机连接到网络并收发数据,必须为其分配 IP 地址。IP 地址分为两类。 -- IPV4(Internet Protocol version 4)4 字节地址族 -- IPV6(Internet Protocol version 6)16 字节地址族 +- IPv4(Internet Protocol version 4)4 字节地址族 +- IPv6(Internet Protocol version 6)16 字节地址族 -两者之间的主要差别是 IP 地址所用的字节数,目前通用的是 IPV4 , IPV6 的普及还需要时间。 +两者之间的主要差别是 IP 地址所用的字节数,目前通用的是 IPv4,IPv6 的普及还需要时间。 -IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机)地址,且分为 A、B、C、D、E 等类型。 +IPv4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机)地址,且分为 A、B、C、D、E 等类型。 ![](https://i.loli.net/2019/01/13/5c3ab0eb17bbe.png) @@ -29,7 +29,7 @@ IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机) #### 3.1.2 网络地址分类与主机地址边界 -只需通过IP地址的第一个字节即可判断网络地址占用的总字节数,因为我们根据IP地址的边界区分网络地址,如下所示: +只需通过 IP 地址的第一个字节即可判断网络地址占用的总字节数,因为我们根据 IP 地址的边界区分网络地址,如下所示: - A 类地址的首字节范围为:0~127 - B 类地址的首字节范围为:128~191 @@ -38,28 +38,28 @@ IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机) 还有如下这种表示方式: - A 类地址的首位以 0 开始 -- B 类地址的前2位以 10 开始 -- C 类地址的前3位以 110 开始 +- B 类地址的前 2 位以 10 开始 +- C 类地址的前 3 位以 110 开始 因此套接字收发数据时,数据传到网络后即可轻松找到主机。 #### 3.1.3 用于区分套接字的端口号 -IP地址用于区分计算机,只要有IP地址就能向目标主机传输数据,但是只有这些还不够,我们需要把信息传输给具体的应用程序。 +IP 地址用于区分计算机,只要有 IP 地址就能向目标主机传输数据,但是只有这些还不够,我们需要把信息传输给具体的应用程序。 所以计算机一般有 NIC(网络接口卡)数据传输设备。通过 NIC 接收的数据内有端口号,操作系统参考端口号把信息传给相应的应用程序。 -端口号由 16 位构成,可分配的端口号范围是 0~65535 。但是 0~1023 是知名端口,一般分配给特定的应用程序,所以应当分配给此范围之外的值。 +端口号由 16 位构成,可分配的端口号范围是 0~65535。但是 0~1023 是知名端口,一般分配给特定的应用程序,所以应当分配给此范围之外的值。 虽然端口号不能重复,但是 TCP 套接字和 UDP 套接字不会共用端口号,所以允许重复。如果某 TCP 套接字使用了 9190 端口号,其他 TCP 套接字就无法使用该端口号,但是 UDP 套接字可以使用。 -总之,数据传输目标地址同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序。 +总之,数据传输目标地址同时包含 IP 地址和端口号,只有这样,数据才会被传输到最终的目的应用程序。 ### 3.2 地址信息的表示 -应用程序中使用的IP地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。 +应用程序中使用的 IP 地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。 -#### 3.2.1 表示 IPV4 地址的结构体 +#### 3.2.1 表示 IPv4 地址的结构体 结构体的定义如下 @@ -73,20 +73,20 @@ struct sockaddr_in }; ``` -该结构体中提到的另一个结构体 in_addr 定义如下,它用来存放 32 位IP地址 +该结构体中提到的另一个结构体 in_addr 定义如下,它用来存放 32 位 IP 地址 ```c struct in_addr { - in_addr_t s_addr; //32位IPV4地址 -} + in_addr_t s_addr; //32位IPv4地址 +}; ``` 关于以上两个结构体的一些数据类型: | 数据类型名称 | 数据类型说明 | 声明的头文件 | | :----------: | :----------------------------------: | :----------: | -| int 8_t | signed 8-bit int | sys/types.h | +| int8_t | signed 8-bit int | sys/types.h | | uint8_t | unsigned 8-bit int (unsigned char) | sys/types.h | | int16_t | signed 16-bit int | sys/types.h | | uint16_t | unsigned 16-bit int (unsigned short) | sys/types.h | @@ -94,23 +94,23 @@ struct in_addr | uint32_t | unsigned 32-bit int (unsigned long) | sys/types.h | | sa_family_t | 地址族(address family) | sys/socket.h | | socklen_t | 长度(length of struct) | sys/socket.h | -| in_addr_t | IP地址,声明为 uint_32_t | netinet/in.h | -| in_port_t | 端口号,声明为 uint_16_t | netinet/in.h | +| in_addr_t | IP地址,声明为 uint32_t | netinet/in.h | +| in_port_t | 端口号,声明为 uint16_t | netinet/in.h | -为什么要额外定义这些数据类型呢?这是考虑扩展性的结果 +为什么要额外定义这些数据类型呢?这是考虑扩展性的结果。 #### 3.2.2 结构体 sockaddr_in 的成员分析 - 成员 sin_family -每种协议适用的地址族不同,比如,IPV4 使用 4 字节的地址族,IPV6 使用 16 字节的地址族。 +每种协议适用的地址族不同,比如,IPv4 使用 4 字节的地址族,IPv6 使用 16 字节的地址族。 > 地址族 | 地址族(Address Family) | 含义 | | ------------------------ | ---------------------------------- | -| AF_INET | IPV4用的地址族 | -| AF_INET6 | IPV6用的地址族 | +| AF_INET | IPv4 用的地址族 | +| AF_INET6 | IPv6 用的地址族 | | AF_LOCAL | 本地通信中采用的 Unix 协议的地址族 | AF_LOCAL 只是为了说明具有多种地址族而添加的。 @@ -121,7 +121,7 @@ AF_LOCAL 只是为了说明具有多种地址族而添加的。 - 成员 sin_addr - 该成员保存 32 位 IP 地址信息,且也以网络字节序保存 + 该成员保存 32 位 IP 地址信息,且也以网络字节序保存。 - 成员 sin_zero @@ -134,21 +134,21 @@ AF_LOCAL 只是为了说明具有多种地址族而添加的。 error_handling("bind() error"); ``` - 此处 bind 第二个参数期望得到的是 sockaddr 结构体变量的地址值,包括地址族、端口号、IP地址等。 + 此处 bind 第二个参数期望得到的是 sockaddr 结构体变量的地址值,包括地址族、端口号、IP 地址等。 ```c struct sockaddr { sa_family_t sin_family; //地址族 char sa_data[14]; //地址信息 - } + }; ``` - 此结构体 sa_data 保存的地址信息中需要包含IP地址和端口号,剩余部分应该填充 0 ,但是这样对于包含地址的信息非常麻烦,所以出现了 sockaddr_in 结构体,然后强制转换成 sockaddr 类型,则生成符合 bind 条件的参数。 + 此结构体 sa_data 保存的地址信息中需要包含 IP 地址和端口号,剩余部分应该填充 0,但是这样对于包含地址的信息非常麻烦,所以出现了 sockaddr_in 结构体,然后强制转换成 sockaddr 类型,则生成符合 bind 条件的参数。 ### 3.3 网络字节序与地址变换 -不同的 CPU 中,4 字节整数值1在内存空间保存方式是不同的。 +不同的 CPU 中,4 字节整数值 1 在内存空间保存方式是不同的。 有些 CPU 这样保存: @@ -162,14 +162,14 @@ AF_LOCAL 只是为了说明具有多种地址族而添加的。 00000001 00000000 00000000 00000000 ``` -两种一种是顺序保存,一种是倒序保存 。 +两种一种是顺序保存,一种是倒序保存。 #### 3.3.1 字节序(Order)与网络字节序 CPU 保存数据的方式有两种,这意味着 CPU 解析数据的方式也有 2 种: - 大端序(Big Endian):高位字节存放到低位地址 -- 小端序(Little Endian):高位字节存放到高位地址 +- 小端序(Little Endian):低位字节存放到低位地址 ![big.png](https://i.loli.net/2019/01/13/5c3ac9c1b2550.png) ![small.png](https://i.loli.net/2019/01/13/5c3ac9c1c3348.png) @@ -195,14 +195,14 @@ unsigned long ntohl(unsigned long); - htons 的 h 代表主机(host)字节序。 - htons 的 n 代表网络(network)字节序。 -- s 代表两个字节的 short 类型,因此以 s 为后缀的函数用于端口转换 -- l 代表四个字节的 long 类型,所以以 l 为后缀的函数用于 IP 地址转换 +- s 代表两个字节的 short 类型,因此以 s 为后缀的函数用于端口转换。 +- l 代表四个字节的 long 类型,所以以 l 为后缀的函数用于 IP 地址转换。 下面的代码是示例,说明以上函数调用过程: [endian_conv.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch03/endian_conv.c) -```cpp +```c #include #include int main(int argc, char *argv[]) @@ -246,9 +246,9 @@ Network ordered address: 0x78563412 #### 3.4.1 将字符串信息转换为网络字节序的整数型 -sockaddr_in 中需要的是 32 位整数型,但是我们只熟悉点分十进制表示法,那么改如何把类似于 201.211.214.36 转换为 4 字节的整数类型数据呢 ?幸运的是,有一个函数可以帮助我们完成它,该函数将字符串形式的 IP 地址转换为网络字节序形式的 32 位整数型数据。 +sockaddr_in 中需要的是 32 位整数型,但是我们只熟悉点分十进制表示法,那么该如何把类似于 201.211.214.36 转换为 4 字节的整数类型数据呢?幸运的是,有一个函数可以帮助我们完成它,该函数将字符串形式的 IP 地址转换为网络字节序形式的 32 位整数型数据。 -```C +```c #include in_addr_t inet_addr(const char *string); //成功时返回 32 位大端序整数型值,失败时返回 INADDR_NONE @@ -295,9 +295,9 @@ Network ordered integer addr: 0x4030201 Error occured! ``` -1个字节能表示的最大整数是255,所以代码中 addr2 是错误的IP地址。从运行结果看,inet_addr 不仅可以转换地址,还可以检测有效性。 +1 个字节能表示的最大整数是 255,所以代码中 addr2 是错误的 IP 地址。从运行结果看,inet_addr 不仅可以转换地址,还可以检测有效性。 -inet_aton 函数与 inet_addr 函数在功能上完全相同,也是将字符串形式的IP地址转换成整数型的IP地址。只不过该函数用了 in_addr 结构体,且使用频率更高。 +inet_aton 函数与 inet_addr 函数在功能上完全相同,也是将字符串形式的 IP 地址转换成整数型的 IP 地址。只不过该函数用了 in_addr 结构体,且使用频率更高。 ```c #include @@ -341,7 +341,7 @@ void error_handling(char *message) 编译运行: -```c +```shell gcc inet_aton.c -o aton ./aton ``` @@ -354,7 +354,7 @@ Network ordered integer addr: 0x4f7ce87f 可以看出,已经成功的把转换后的地址放进了 addr_inet.sin_addr.s_addr 中。 -还有一个函数,与 inet_aton() 正好相反,它可以把网络字节序整数型IP地址转换成我们熟悉的字符串形式,函数原型如下: +还有一个函数,与 inet_aton() 正好相反,它可以把网络字节序整数型 IP 地址转换成我们熟悉的字符串形式,函数原型如下: ```c #include @@ -362,7 +362,7 @@ char *inet_ntoa(struct in_addr adr); //成功时返回保存转换结果的字符串地址值,失败时返回 NULL 空指针 ``` -该函数将通过参数传入的整数型IP地址转换为字符串格式并返回。但要小心,返回值为 char 指针,返回字符串地址意味着字符串已经保存在内存空间,但是该函数未向程序员要求分配内存,而是再内部申请了内存保存了字符串。也就是说调用了该函数后要立即把信息复制到其他内存空间。因为,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之,再次调用 inet_ntoa 函数前返回的字符串地址是有效的。若需要长期保存,则应该将字符串复制到其他内存空间。 +该函数将通过参数传入的整数型 IP 地址转换为字符串格式并返回。但要小心,返回值为 char 指针,返回字符串地址意味着字符串已经保存在内存空间,但是该函数未向程序员要求分配内存,而是在内部申请了内存保存了字符串。也就是说调用了该函数后要立即把信息复制到其他内存空间。因为,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之,再次调用 inet_ntoa 函数前返回的字符串地址是有效的。若需要长期保存,则应该将字符串复制到其他内存空间。 示例: @@ -400,9 +400,9 @@ gcc inet_ntoa.c -o ntoa ./ntoa ``` -输出: +输出: -```c +``` Dotted-Decimal notation1: 1.2.3.4 Dotted-Decimal notation2: 1.1.1.1 Dotted-Decimal notation3: 1.2.3.4 @@ -414,7 +414,7 @@ Dotted-Decimal notation3: 1.2.3.4 ```c struct sockaddr_in addr; -char *serv_ip = "211.217,168.13"; //声明IP地址族 +char *serv_ip = "211.217.168.13"; //声明IP地址族 char *serv_port = "9190"; //声明端口号字符串 memset(&addr, 0, sizeof(addr)); //结构体变量 addr 的所有成员初始化为0 addr.sin_family = AF_INET; //制定地址族 @@ -430,33 +430,35 @@ addr.sin_port = htons(atoi(serv_port)); //基于字符串的IP地址端口号 > 答案仅代表本人个人观点,不一定正确 -1. **IP地址族 IPV4 与 IPV6 有什么区别?在何种背景下诞生了 IPV6?** +1. **IP 地址族 IPv4 与 IPv6 有什么区别?在何种背景下诞生了 IPv6?** - 答:主要差别是IP地址所用的字节数,目前通用的是IPV4,目前IPV4的资源已耗尽,所以诞生了IPV6,它具有更大的地址空间。 + 答:主要差别是 IP 地址所用的字节数,IPv4 使用 4 字节地址(约 43 亿个地址),IPv6 使用 16 字节地址(约 3.4×10^38 个地址)。目前通用的是 IPv4,但由于 IPv4 地址资源已近枯竭,所以诞生了 IPv6,它具有巨大的地址空间,可以满足未来互联网发展的需求。 -2. **通过 IPV4 网络 ID 、主机 ID 及路由器的关系说明公司局域网的计算机传输数据的过程** +2. **通过 IPv4 网络 ID、主机 ID 及路由器的关系说明公司局域网的计算机传输数据的过程。** - 答:网络ID是为了区分网络而设置的一部分IP地址,假设向`www.baidu.com`公司传输数据,该公司内部构建了局域网。因为首先要向`baidu.com`传输数据,也就是说并非一开始就浏览所有四字节IP地址,首先找到网络地址,进而由`baidu.com`(构成网络的路由器)接收到数据后,传输到主机地址。比如向 203.211.712.103 传输数据,那就先找到 203.211.172 ,然后由这个网络的网关找主机号为 172 的机器传输数据。 + 答:网络 ID 是为了区分网络而设置的一部分 IP 地址,主机 ID 是为了区分网络内的主机而设置的部分。假设向某公司传输数据,该公司内部构建了局域网。数据传输时首先根据网络 ID 找到目标网络(由路由器或网关接收),然后由该网络的路由器根据主机 ID 将数据转发给具体的主机。 -3. **套接字地址分为IP地址和端口号,为什么需要IP地址和端口号?或者说,通过IP地址可以区分哪些对象?通过端口号可以区分哪些对象?** + 例如向 IP 地址 203.211.172.103 传输数据,其中 203.211.172 是网络 ID,103 是主机 ID。数据首先被路由到网络 203.211.172(由该网络的网关路由器接收),然后路由器根据主机 ID 103 将数据传递给局域网内对应的主机。 - 答:有了IP地址和端口号,才能把数据准确的传送到某个应用程序中。通过IP地址可以区分具体的主机,通过端口号可以区分主机上的应用程序。 +3. **套接字地址分为 IP 地址和端口号,为什么需要 IP 地址和端口号?或者说,通过 IP 地址可以区分哪些对象?通过端口号可以区分哪些对象?** -4. **请说明IP地址的分类方法,并据此说出下面这些IP的分类。** + 答:有了 IP 地址和端口号,才能把数据准确地传送到某个应用程序中。通过 IP 地址可以区分网络中不同的主机(计算机),通过端口号可以区分同一主机上不同的应用程序(套接字)。 - - 214.121.212.102(C类) - - 120.101.122.89(A类) - - 129.78.102.211(B类) +4. **请说明 IP 地址的分类方法,并据此说出下面这些 IP 的分类。** - 分类方法:A 类地址的首字节范围为:0~127、B 类地址的首字节范围为:128~191、C 类地址的首字节范围为:192~223 + - 214.121.212.102(C 类) + - 120.101.122.89(A 类) + - 129.78.102.211(B 类) + + 分类方法:A 类地址的首字节范围为:0~127、B 类地址的首字节范围为:128~191、C 类地址的首字节范围为:192~223。 5. **计算机通过路由器和交换机连接到互联网,请说出路由器和交换机的作用。** - 答:路由器表示连接到互联网的网络 ID,用于在不同网络间转发数据。交换机用于组织一个局域网内部的主机,局域网内部的主机可以通过交换机直接通信。如果局域网内的主机想要和其他网络的主机通信,需要通过路由器转发到目的网络,接收到的其他网络发来的数据先通过路由器接收,再由路由器根据主机号转发到交换机寻找具体的主机。 + 答:路由器用于连接不同的网络,负责在不同网络间转发数据包,根据 IP 地址的网络 ID 进行路由选择。交换机用于组织局域网内部的主机连接,局域网内部的主机可以通过交换机直接通信(基于 MAC 地址)。如果局域网内的主机想要和其他网络的主机通信,需要通过路由器转发到目的网络;接收到的其他网络发来的数据先由路由器接收,再由路由器转发到交换机,最终找到具体的主机。 6. **什么是知名端口?其范围是多少?知名端口中具有代表性的 HTTP 和 FTP 的端口号各是多少?** - 答:知名端口是要把该端口分配给特定的应用程序,范围是 0~1023 ,HTTP 的端口号是 80 ,FTP 的端口号是20和21 + 答:知名端口(Well-Known Ports)是保留给特定应用程序使用的端口,范围是 0~1023。HTTP 的默认端口号是 80,FTP 的默认端口号是 20(数据连接)和 21(控制连接)。 7. **向套接字分配地址的 bind 函数原型如下:** @@ -467,22 +469,33 @@ addr.sin_port = htons(atoi(serv_port)); //基于字符串的IP地址端口号 **而调用时则用:** ```c - bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr) + bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)); ``` **此处 serv_addr 为 sockaddr_in 结构体变量。与函数原型不同,传入的是 sockaddr_in 结构体变量,请说明原因。** - 答:因为对于详细的地址信息使用 sockaddr 类型传递特别麻烦,进而有了 sockaddr_in 类型,其中基本与前面的类型保持一致,还有 sa_sata[4] 来保存地址信息,剩余全部填 0,所以强制转换后,不影响程序运行。 + 答:sockaddr 结构体的 sa_data 成员是一个 14 字节的数组,需要手动填充 IP 地址、端口号等信息,使用起来非常麻烦。因此设计了 sockaddr_in 结构体,它将地址信息分成了 sin_family、sin_port、sin_addr 等独立的成员,便于使用。由于 sockaddr_in 和 sockaddr 在内存布局上是兼容的(大小相同,前两个字段偏移一致),且 bind 函数需要的是 sockaddr 类型的指针,所以可以将 sockaddr_in 结构体变量的地址强制转换为 sockaddr* 类型传入,不影响程序的正确运行。 -8. **请解释大端序,小端序、网络字节序,并说明为何需要网络字节序。** +8. **请解释大端序、小端序、网络字节序,并说明为何需要网络字节序。** - 答:CPU 向内存保存数据有两种方式,大端序是高位字节存放低位地址,小端序是高位字节存放高位地址,网络字节序是为了方便传输的信息统一性,统一成了大端序。 + 答: + - 大端序(Big Endian):高位字节存放在低位地址 + - 小端序(Little Endian):低位字节存放在低位地址 + - 网络字节序:为了在网络上统一传输数据的格式,约定使用大端序作为网络字节序 + + 需要网络字节序的原因:不同的 CPU 架构采用不同的字节序(如 x86 是小端序,PowerPC 是大端序),如果两台字节序不同的计算机直接传输数据,接收方会错误解析数据。因此约定网络传输统一使用大端序,发送前将主机字节序转换为网络字节序,接收后再转换回主机字节序。 9. **大端序计算机希望把 4 字节整数型 12 传递到小端序计算机。请说出数据传输过程中发生的字节序变换过程。** - 答:0x12->0x21 + 答:整数 12 的十六进制表示为 0x0000000C。 -10. **怎样表示回送地址?其含义是什么?如果向会送地址处传输数据将会发生什么情况?** + - 在大端序计算机上,内存表示为:`00 00 00 0C`(从低地址到高地址) + - 发送时已经是网络字节序(大端序),无需转换,直接发送 `00 00 00 0C` + - 小端序计算机接收到 `00 00 00 0C` 后,通过 ntohl() 函数转换为小端序 + - 转换后在小端序计算机内存中为:`0C 00 00 00`(从低地址到高地址) - 答:127.0.0.1 表示回送地址,指的是计算机自身的IP地址,无论什么程序,一旦使用回送地址发送数据,协议软件立即返回,不进行任何网络传输。 + 注意:字节序转换函数处理的是整个 4 字节整数,而非单字节。对于单字节值 12(0x0C),无论在大端序还是小端序机器上,其值都是 0x0C,不会变成 0x21。 +10. **怎样表示回送地址?其含义是什么?如果向回送地址处传输数据将会发生什么情况?** + + 答:回送地址表示为 127.0.0.1(IPv4)或 ::1(IPv6),指的是计算机自身的 IP 地址,也称为本地环回地址。无论什么程序,一旦使用回送地址发送数据,数据不会真正发送到网络上,而是在本机协议栈内循环,协议软件立即将数据返回给本机的接收端,不进行任何网络传输。回送地址常用于本地服务测试和网络程序调试。 diff --git a/ch04/README.md b/ch04/README.md index 4398fc3..3086015 100644 --- a/ch04/README.md +++ b/ch04/README.md @@ -30,7 +30,7 @@ IP 层解决数据传输中的路径选择问题,只需照此路径传输数 IP 层只关注一个数据包(数据传输基本单位)的传输过程。因此,即使传输多个数据包,每个数据包也是由 IP 层实际传输的,也就是说传输顺序及传输本身是不可靠的。若只利用IP层传输数据,则可能导致后传输的数据包B比先传输的数据包A提早到达。另外,传输的数据包A、B、C中可能只收到A和C,甚至收到的C可能已经损毁 。反之,若添加 TCP 协议则按照如下对话方式进行数据交换。 -> 主机A:正确接受第二个数据包 +> 主机A:正确接收第二个数据包 > > 主机B:恩,知道了 > @@ -56,7 +56,7 @@ IP 层只关注一个数据包(数据传输基本单位)的传输过程。 #### 4.2.2 进入等待连接请求状态 -已经调用了 bind 函数给套接字分配地址,接下来就是要通过调用 listen 函数进入等待链接请求状态。只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。客户端可以调用 connect 函数,向服务端请求连接,对于客户端发来的请求,先进入连接请求等待队列,等待服务端受理请求。 +已经调用了 bind 函数给套接字分配地址,接下来就是要通过调用 listen 函数进入等待连接请求状态。只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。客户端可以调用 connect 函数,向服务端请求连接,对于客户端发来的请求,先进入连接请求等待队列,等待服务端受理请求。 ```c #include @@ -120,7 +120,7 @@ addrlen: 第二个结构体参数 servaddr 变量的字节长度 注意:**接受连接**不代表服务端调用 accept 函数,其实只是服务器端把连接请求信息记录到等待队列。因此 connect 函数返回后并不应该立即进行数据交换。 -客户端在调用connect函数时自动分配主机的IP,随机分配端口。无需调用标记的bind函数进行分配。 +客户端在调用 connect 函数时自动分配主机的 IP,随机分配端口。无需调用显式的 bind 函数进行分配。 #### 4.2.6 回顾 Hello World 客户端 @@ -140,7 +140,7 @@ addrlen: 第二个结构体参数 servaddr 变量的字节长度 ![](https://i.loli.net/2019/01/14/5c3c35a773b8c.png) -- 客户端只能等到服务端调用 listen 函数后才才能调用 connect 函数 +- 客户端只能等到服务端调用 listen 函数后才能调用 connect 函数 - 服务器端可能会在客户端调用 connect 之前调用 accept 函数,这时服务器端进入阻塞(blocking)状态,直到客户端调用 connect 函数后接收到连接请求。 ### 4.3 实现迭代服务端/客户端 @@ -162,7 +162,7 @@ addrlen: 第二个结构体参数 servaddr 变量的字节长度 - 服务器端在同一时刻只与一个客户端相连,并提供回声服务。 - 服务器端依次向 5 个客户端提供服务并退出。 - 客户端接受用户输入的字符串并发送到服务器端。 -- 服务器端将接受的字符串数据传回客户端,即「回声」 +- 服务器端将接收的字符串数据传回客户端,即「回声」 - 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。 以下是服务端与客户端的代码: @@ -208,7 +208,21 @@ client: ### 4.4 基于 Windows 的实现 -暂略 +Windows 平台下的 Socket 编程(Winsock)与 Linux 平台基本类似,主要区别如下: + +1. **头文件**:Windows 使用 `winsock2.h` 和 `ws2tcpip.h`,Linux 使用 `sys/socket.h` 等头文件。 + +2. **库文件**:Windows 需要链接 `ws2_32.lib` 库。 + +3. **初始化**:Windows 使用 Winsock 函数前需要调用 `WSAStartup` 进行初始化,程序结束前需要调用 `WSACleanup` 清理。 + +4. **套接字类型**:Windows 中 `SOCKET` 类型是 `HANDLE`(句柄),而 Linux 中是 `int`(文件描述符)。Windows 的 `INVALID_SOCKET` 对应 Linux 的 `-1`。 + +5. **关闭套接字**:Windows 使用 `closesocket`,Linux 使用 `close`。 + +6. **错误处理**:Windows 使用 `WSAGetLastError()` 获取错误码,Linux 使用 `errno` 全局变量。 + +7. **函数返回值**:大部分函数返回值含义相同,但 Windows 中部分函数的返回类型可能不同(如 `recv` 返回 `int` 而非 `ssize_t`)。 ### 4.5 习题 @@ -224,7 +238,7 @@ client: 3. **为何需要把 TCP/IP 协议栈分成 4 层(或7层)?开放式回答。** - 答:ARPANET 的研制经验表明,对于复杂的计算机网络协议,其结构应该是层次式的。分册的好处:①隔层之间是独立的②灵活性好③结构上可以分隔开④易于实现和维护⑤能促进标准化工作。 + 答:ARPANET 的研制经验表明,对于复杂的计算机网络协议,其结构应该是层次式的。分层的好处:①层与层之间是独立的②灵活性好③结构上可以分隔开④易于实现和维护⑤能促进标准化工作。 4. **客户端调用 connect 函数向服务器端发送请求。服务器端调用哪个函数后,客户端可以调用 connect 函数?** @@ -232,8 +246,8 @@ client: 5. **什么时候创建连接请求等待队列?它有何种作用?与 accept 有什么关系?** - 答:服务端调用 listen 函数后,accept函数正在处理客户端请求时, 更多的客户端发来了请求连接的数据,此时,就需要创建连接请求等待队列。以便于在accept函数处理完手头的请求之后,按照正确的顺序处理后面正在排队的其他请求。与accept函数的关系:accept函数受理连接请求等待队列中待处理的客户端连接请求。 + 答:服务端调用 `listen` 函数时创建连接请求等待队列。作用:当多个客户端几乎同时向服务端发起连接请求时,服务端在调用 `accept` 函数处理当前请求的同时,后续的连接请求会进入该队列等待。与 `accept` 函数的关系:`accept` 函数从连接请求等待队列的队头取出一个连接请求进行处理,如果队列为空则 `accept` 函数会阻塞等待。 6. **客户端中为何不需要调用 bind 函数分配地址?如果不调用 bind 函数,那何时、如何向套接字分配IP地址和端口号?** - 答:在调用 connect 函数时分配了地址,客户端IP地址和端口在调用 connect 函数时自动分配,无需调用标记的 bind 函数进行分配。 + 答:客户端通常不需要显式调用 `bind` 函数分配地址。如果不调用 `bind` 函数,在调用 `connect` 函数时,操作系统会自动为客户端套接字分配 IP 地址(使用本机网络接口的 IP)和端口号(从临时端口范围中随机选择一个未使用的端口)。 diff --git a/ch05/README.md b/ch05/README.md index cd19cc2..75aff56 100644 --- a/ch05/README.md +++ b/ch05/README.md @@ -2,7 +2,7 @@ 本章代码,在[TCP-IP-NetworkNote](https://github.com/riba2534/TCP-IP-NetworkNote)中可以找到。 -上一章仅仅是从编程角度学习实现方法,并未详细讨论 TCP 的工作原理。因此,本章将想次讲解 TCP 中必要的理论知识,还将给出第 4 章客户端问题的解决方案。 +上一章仅仅是从编程角度学习实现方法,并未详细讨论 TCP 的工作原理。因此,本章将详细讲解 TCP 中必要的理论知识,还将给出第 4 章客户端问题的解决方案。 ### 5.1 回声客户端的完美实现 @@ -15,14 +15,14 @@ while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0) write(clnt_sock, message, str_len); ``` -接着是客户端代码: +接着是客户端代码: ```c write(sock, message, strlen(message)); str_len = read(sock, message, BUF_SIZE - 1); ``` -二者都在循环调用 read 和 write 函数。实际上之前的回声客户端将 100% 接受字节传输的数据,只不过接收数据时的单位有些问题。扩展客户端代码回顾范围,下面是,客户端的代码: +二者都在循环调用 read 和 write 函数。实际上之前的回声客户端将 100% 接收字节传输的数据,只不过接收数据时的单位有些问题。扩展客户端代码回顾范围,下面是客户端的代码: ```c while (1) @@ -40,11 +40,11 @@ while (1) } ``` -现在应该理解了问题,回声客户端传输的是字符串,而且是通过调用 write 函数一次性发送的。之后还调用一次 read 函数,期待着接受自己传输的字符串,这就是问题所在。 +现在应该理解了问题,回声客户端传输的是字符串,而且是通过调用 write 函数一次性发送的。之后还调用一次 read 函数,期待着接收自己传输的字符串,这就是问题所在。 #### 5.1.2 回声客户端问题的解决办法 -这个问题其实很容易解决,因为可以提前接受数据的大小。若之前传输了20字节长的字符串,则再接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,那么代码如下: +这个问题其实很容易解决,因为可以提前接收数据的大小。若之前传输了 20 字节长的字符串,则在接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,那么代码如下: - [echo_client2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch05/echo_client2.c) @@ -57,15 +57,15 @@ while (1) 现在写一个小程序来体验应用层协议的定义过程。要求: 1. 服务器从客户端获得多个数组和运算符信息。 -2. 服务器接收到数字候对齐进行加减乘运算,然后把结果传回客户端。 +2. 服务器接收到数字后对齐进行加减乘运算,然后把结果传回客户端。 例: -1. 向服务器传递3,5,9的同事请求加法运算,服务器返回3+5+9的结果 +1. 向服务器传递 3, 5, 9 的同时请求加法运算,服务器返回 3+5+9 的结果 2. 请求做乘法运算,客户端会收到`3*5*9`的结果 -3. 如果向服务器传递4,3,2的同时要求做减法,则返回4-3-2的运算结果。 +3. 如果向服务器传递 4, 3, 2 的同时要求做减法,则返回 4-3-2 的运算结果。 -请自己实现一个程序来实现功能。 +请自己实现一个程序来实现以上功能。 我自己的实现: @@ -99,7 +99,7 @@ gcc op_client.c -o opclient gcc op_server.c -o opserver ``` -运行: +运行: ```shell ./opserver 9190 @@ -114,9 +114,9 @@ gcc op_server.c -o opserver #### 5.2.1 TCP 套接字中的 I/O 缓冲 -TCP 套接字的数据收发无边界。服务器即使调用 1 次 write 函数传输 40 字节的数据,客户端也有可能通过 4 次 read 函数调用每次读取 10 字节。但此处也有一些疑问,服务器一次性传输了 40 字节,而客户端竟然可以缓慢的分批接受。客户端接受 10 字节后,剩下的 30 字节在何处等候呢? +TCP 套接字的数据收发无边界。服务器即使调用 1 次 write 函数传输 40 字节的数据,客户端也有可能通过 4 次 read 函数调用每次读取 10 字节。但此处也有一些疑问,服务器一次性传输了 40 字节,而客户端竟然可以缓慢的分批接收。客户端接收 10 字节后,剩下的 30 字节在何处等候呢? -实际上,write 函数调用后并非立即传输数据, read 函数调用后也并非马上接收数据。如图所示,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。 +实际上,write 函数调用后并非立即传输数据,read 函数调用后也并非马上接收数据。如图所示,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。 ![](https://i.loli.net/2019/01/16/5c3ea41cd93c6.png) @@ -135,7 +135,7 @@ I/O 缓冲特性可以整理如下: > - A:你好,最多可以向我传递 50 字节 > - B:好的 -> - A:我腾出了 20 字节的空间,最多可以接受 70 字节 +> - A:我腾出了 20 字节的空间,最多可以接收 70 字节 > - B:好的 数据收发也是如此,因此 TCP 中不会因为缓冲溢出而丢失数据。 @@ -164,7 +164,7 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 > [SYN] SEQ : 1000 , ACK:- -该消息中的 SEQ 为 1000 ,ACK 为空,而 SEQ 为1000 的含义如下: +该消息中的 SEQ 为 1000,ACK 为空,而 SEQ 为 1000 的含义如下: > 现在传递的数据包的序号为 1000,如果接收无误,请通知我向您传递 1001 号数据包。 @@ -174,11 +174,11 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 此时 SEQ 为 2000,ACK 为 1001,而 SEQ 为 2000 的含义如下: -> 现传递的数据包号为 2000 ,如果接受无误,请通知我向您传递 2001 号数据包。 +> 现在传递的数据包号为 2000,如果接收无误,请通知我向您传递 2001 号数据包。 而 ACK 1001 的含义如下: -> 刚才传输的 SEQ 为 1000 的数据包接受无误,现在请传递 SEQ 为 1001 的数据包。 +> 刚才传输的 SEQ 为 1000 的数据包接收无误,现在请传递 SEQ 为 1001 的数据包。 对于主机 A 首次传输的数据包的确认消息(ACK 1001)和为主机 B 传输数据做准备的同步消息(SEQ 2000)捆绑发送。因此,此种类消息又称为 SYN+ACK。 @@ -192,7 +192,7 @@ TCP 在实际通信中也会经过三次对话过程,因此,该过程又被 ![](https://i.loli.net/2019/01/16/5c3ed1a97ce2b.png) -图上给出了主机 A 分成 2 个数据包向主机 B 传输 200 字节的过程。首先,主机 A 通过 1 个数据包发送 100 个字节的数据,数据包的 SEQ 为 1200 。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。 +图上给出了主机 A 分成 2 个数据包向主机 B 传输 200 字节的过程。首先,主机 A 通过 1 个数据包发送 100 个字节的数据,数据包的 SEQ 为 1200。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。 此时的 ACK 号为 1301 而不是 1201,原因在于 ACK 号的增量为传输的数据字节数。假设每次 ACK 号不加传输的字节数,这样虽然可以确认数据包的传输,但无法明确 100 个字节全都正确传递还是丢失了一部分,比如只传递了 80 字节。因此按照如下公式传递 ACK 信息: @@ -217,7 +217,7 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 ![](https://i.loli.net/2019/01/16/5c3ed7503c18c.png) -图中数据包内的 FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过过程经历 4 个阶段,因此又称四次握手(Four-way handshaking)。SEQ 和 ACK 的含义与之前讲解的内容一致,省略。图中,主机 A 传递了两次 ACK 5001,也许这里会有困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收了 ACK 消息后未接收到的数据重传的。 +图中数据包内的 FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过程经历 4 个阶段,因此又称四次握手(Four-way handshaking)。SEQ 和 ACK 的含义与之前讲解的内容一致,省略。图中,主机 A 传递了两次 ACK 5001,也许这里会有困惑。其实,第二次 FIN 数据包中的 ACK 5001 只是因为接收了 ACK 消息后未接收到的数据重传的。 ### 5.3 基于 Windows 的实现 @@ -229,13 +229,13 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 1. **请说明 TCP 套接字连接设置的三次握手过程。尤其是 3 次数据交换过程每次收发的数据内容。** - 答:TCP套接字的生命周期主要可分为3个部分: ①与对方套接字建立连接 ②与对方套接字进行数据交换 ③断开与对方套接字的连接。 + 答:TCP 套接字的生命周期主要可分为 3 个部分:①与对方套接字建立连接 ②与对方套接字进行数据交换 ③断开与对方套接字的连接。 -其中,在第一步建立连接的阶段,又可细分为3个步骤(即`三次握手`):①由主机1给主机2发送初始的SEQ,首次连接请求是关键字是SYN,表示收发数据前同步传输的消息,此时报文的ACK一般为空。②主机2收到报文以后,给主机 1 传递信息,用一个新的SEQ表示自己的序号,然后ACK代表已经接受到主机1的消息,希望接受下一个消息③主机1收到主机2的确认以后,还需要给主机2给出确认,此时再发送一次SEQ和ACK。 + 其中,在第一步建立连接的阶段,又可细分为 3 个步骤(即`三次握手`):①由主机 1 给主机 2 发送初始的 SEQ,首次连接请求的关键字是 SYN,表示收发数据前同步传输的消息,此时报文的 ACK 一般为空。②主机 2 收到报文以后,给主机 1 传递信息,用一个新的 SEQ 表示自己的序号,然后 ACK 代表已经接收到主机 1 的消息,希望接收下一个消息。③主机 1 收到主机 2 的确认以后,还需要给主机 2 给出确认,此时再发送一次 SEQ 和 ACK。 -2. **TCP 是可靠的数据传输协议,但在通过网络通信的过程中可能丢失数据。请通过 ACK 和 SEQ 说明 TCP 通过和何种机制保证丢失数据的可靠传输。** +2. **TCP 是可靠的数据传输协议,但在通过网络通信的过程中可能丢失数据。请通过 ACK 和 SEQ 说明 TCP 通过何种机制保证丢失数据的可靠传输。** - 答:通过超时重传机制来保证,如果报文发出去的特定时间内,发送消息的主机没有收到另一个主机的回复,那么就继续发送这条消息,直到收到回复为止。 + 答:TCP 通过超时重传机制和确认应答(ACK)机制来保证可靠传输。具体过程如下:发送方每次发送数据时都会带上一个序列号(SEQ),接收方收到数据后会返回一个确认号(ACK),ACK 号的值等于 SEQ 号加上接收到的字节数再加 1,表示期待接收的下一个序列号。如果发送方在规定时间内没有收到对应的 ACK 确认,TCP 套接字的计时器会发生超时,发送方会重传该数据包。通过这种 SEQ/ACK 机制配合超时重传,TCP 可以确保数据包的可靠传输。 3. **TCP 套接字中调用 write 和 read 函数时数据如何移动?结合 I/O 缓冲进行说明。** @@ -243,4 +243,4 @@ TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传 4. **对方主机的输入缓冲剩余 50 字节空间时,若本主机通过 write 函数请求传输 70 字节,请问 TCP 如何处理这种情况?** - 答:TCP 中有滑动窗口控制协议,所以传输的时候会保证传输的字节数小于等于自己能接受的字节数。 + 答:TCP 中有滑动窗口控制协议,所以传输的时候会保证传输的字节数小于等于对方能接收的字节数。在这种情况下,TCP 只会发送 50 字节的数据(或者更少),剩余的 20 字节会保留在发送方的输出缓冲中,等待对方腾出更多空间后再发送。write 函数可能会阻塞等待,或者返回实际发送的字节数(部分写入)。 diff --git a/ch06/README.md b/ch06/README.md index 7ed73bd..719007e 100644 --- a/ch06/README.md +++ b/ch06/README.md @@ -121,7 +121,7 @@ UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的 前面说得 TCP 数据传输中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义」 -相反,UDP 是具有数据边界的下一,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数应该完全一致,这样才能保证接收全部已经发送的数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。通过一个例子来进行验证: +相反,UDP 是具有数据边界的协议,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数应该完全一致,这样才能保证接收全部已经发送的数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。通过一个例子来进行验证: - [bound_host1.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch06/bound_host1.c) - [bound_host2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch06/bound_host2.c) @@ -186,9 +186,9 @@ connect(sock, (struct sockaddr *)&adr, sizeof(adr)); > 以下答案仅代表本人个人观点,可能不是正确答案。 -1. **UDP 为什么比 TCP 快?为什么 TCP 传输可靠而 TCP 传输不可靠?** +1. **UDP 为什么比 TCP 快?为什么 TCP 传输可靠而 UDP 传输不可靠?** - 答:为了提供可靠的数据传输服务,TCP 在不可靠的IP层进行流控制,而 UDP 缺少这种流控制。所以 UDP 是不可靠的连接。 + 答:为了提供可靠的数据传输服务,TCP 在不可靠的IP层进行流控制,而 UDP 缺少这种流控制。所以 UDP 是不可靠的传输方式。 2. **下面不属于 UDP 特点的是?** @@ -200,6 +200,8 @@ connect(sock, (struct sockaddr *)&adr, sizeof(adr)); 4. **UDP 套接字和 TCP 套接字可以共存。若需要,可以同时在同一主机进行 TCP 和 UDP 数据传输。** 5. 针对 UDP 函数也可以调用 connect 函数,此时 UDP 套接字跟 TCP 套接字相同,也需要经过 3 次握手阶段。 + 答:第2句和第5句不属于 UDP 的特点(即这两句话是错误的)。第2句错误是因为 UDP 只需一个套接字就可以向多个目标传输数据;第5句错误是因为 UDP 调用 connect 函数只是注册目标地址信息,不会进行 TCP 那样的三次握手过程。 + 3. **UDP 数据报向对方主机的 UDP 套接字传递过程中,IP 和 UDP 分别负责哪些部分?** 答:IP的作用就是让离开主机的 UDP 数据包准确传递到另一个主机。但把 UDP 包最终交给主机的某一 UDP 套接字的过程则是由 UDP 完成的。UDP 的最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。 diff --git a/ch07/README.md b/ch07/README.md index 1b77687..261af1c 100644 --- a/ch07/README.md +++ b/ch07/README.md @@ -10,7 +10,7 @@ TCP 的断开连接过程比建立连接更重要,因为连接过程中一般 #### 7.1.1 单方面断开连接带来的问题 -Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。如图所示: +Linux 的 close 函数和 Windows 的 closesocket 函数意味着完全断开连接。完全断开不仅指无法传输数据,而且也不能接收数据。因此在某些情况下,通信一方单方面的断开套接字连接,显得不太优雅。如图所示: ![](https://i.loli.net/2019/01/18/5c412a8baa2d8.png) @@ -22,11 +22,11 @@ Linux 和 Windows 的 closesocket 函数意味着完全断开连接。完全断 两台主机通过套接字建立连接后进入可交换数据的状态,又称「流形成的状态」。也就是把建立套接字后可交换数据的状态看作一种流。 -此处的流可以比作水流。水朝着一个方向流动,同样,在套接字的流中,数据也止呕能向一个方向流动。因此,为了进行双向通信,需要如图所示的两个流: +此处的流可以比作水流。水朝着一个方向流动,同样,在套接字的流中,数据也只能向一个方向流动。因此,为了进行双向通信,需要如图所示的两个流: ![](https://i.loli.net/2019/01/18/5c412c3ba25dd.png) -一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。 +一旦两台主机之间建立了套接字连接,每个主机就会拥有单独的输入流和输出流。当然,其中一个主机的输入流与另一个主机的输出流相连,而输出流则与另一个主机的输入流相连。另外,本章讨论的「优雅的断开连接方式」只断开其中 1 个流,而非同时断开两个流。Linux 的 close 函数和 Windows 的 closesocket 函数将同时断开这两个流,因此与「优雅」二字还有一段距离。 #### 7.1.3 针对优雅断开的 shutdown 函数 @@ -56,7 +56,7 @@ howto: 传递断开方式信息 > 一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。 -此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的还嫌难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序**阻塞**。 +此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的编写难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序**阻塞**。 > 是否可以让服务器和客户端约定一个代表文件尾的字符? @@ -94,7 +94,25 @@ gcc file_server.c -o fserver ### 7.2 基于 Windows 的实现 -暂略 +Windows 平台下的半关闭实现与 Linux 类似,同样使用 shutdown 函数。函数原型如下: + +```c +#include +int shutdown(SOCKET s, int how); +/* +成功时返回 0,失败时返回 SOCKET_ERROR +s: 需要断开的套接字句柄 +how: 传递断开方式信息 +*/ +``` + +Windows 下的 how 参数取值与 Linux 略有不同: + +- `SD_RECEIVE` : 断开输入流(相当于 Linux 的 SHUT_RD) +- `SD_SEND` : 断开输出流(相当于 Linux 的 SHUT_WR) +- `SD_BOTH` : 同时断开 I/O 流(相当于 Linux 的 SHUT_RDWR) + +使用方法与 Linux 版本基本一致,只需注意参数名称的差异即可。 ### 7.3 习题 @@ -106,8 +124,14 @@ gcc file_server.c -o fserver 2. **Linux 中的 close 函数或 Windows 中的 closesocket 函数属于单方面断开连接的方法,有可能带来一些问题。什么是单方面断开连接?什么情形下会出现问题?** - 答:单方面断开连接就是两台主机正在通信,其中一台主机关闭了所有连接,那么一台主机向另一台主机传输的数据可能会没有接收到而损毁。传输文件的服务器只需连续传输文件数据即可,而客户端不知道需要接收数据到何时。客户端也没有办法无休止的调用输入函数。现在需要一个 EOF 代表数据已经传输完毕,那么这时就需要半关闭,服务端把自己的输出流关了,这时客户端就知数据已经传输完毕,因为服务端的输入流还没关,客户端可以给服务器汇报,接收完毕。 + 答:单方面断开连接是指通信的一方调用 close(Linux)或 closesocket(Windows)函数,同时关闭输入流和输出流。这会导致该主机既不能发送数据也不能接收数据。 + + 问题出现的典型情形是:服务器向客户端传输文件,传输完成后需要接收客户端的确认信息(如「Thank you」)。如果服务器调用 close 函数发送 EOF,虽然客户端能知道文件传输完毕,但服务器也无法再接收客户端发送的确认信息了。解决方法是使用 shutdown 函数进行半关闭,只关闭输出流,保留输入流。 3. **什么是半关闭?针对输出流执行半关闭的主机处于何种状态?半关闭会导致对方主机接收什么消息?** - 答:半关闭就是把输入流或者输出流关了。针对输出流执行半关闭的主机处于可以接收数据而不能发送数据。半关闭会导致对方主机接收一个 EOF 文件结束符。对方就知道你的数据已经传输完毕。 + 答:半关闭是指只关闭套接字的输入流或输出流中的一种,而不是同时关闭两者。 + + 针对输出流执行半关闭(即调用 `shutdown(sock, SHUT_WR)`)的主机处于:可以接收数据,但无法发送数据的状态。 + + 半关闭会导致对方主机的接收函数返回 EOF(文件结束符),对方主机由此得知数据已传输完毕。 diff --git a/ch08/README.md b/ch08/README.md index 3937c9a..5ce601e 100644 --- a/ch08/README.md +++ b/ch08/README.md @@ -49,13 +49,13 @@ struct hostent }; ``` -从上述结构体可以看出,不止返回IP信息,同事还带着其他信息一起返回。域名转换成IP时只需要关注 h_addr_list 。下面简要说明上述结构体的成员: +从上述结构体可以看出,不止返回IP信息,同时还带着其他信息一起返回。域名转换成IP时只需要关注 h_addr_list。下面简要说明上述结构体的成员: - h_name:该变量中存有官方域名(Official domain name)。官方域名代表某一主页,但实际上,一些著名公司的域名并没有用官方域名注册。 - h_aliases:可以通过多个域名访问同一主页。同一IP可以绑定多个域名,因此,除官方域名外还可以指定其他域名。这些信息可以通过 h_aliases 获得。 -- h_addrtype:gethostbyname 函数不仅支持 IPV4 还支持 IPV6 。因此可以通过此变量获取保存在 h_addr_list 的IP地址族信息。若是 IPV4 ,则此变量中存有 AF_INET。 -- h_length:保存IP地址长度。若是 IPV4 地址,因为是 4 个字节,则保存4;IPV6 时,因为是 16 个字节,故保存 16 -- h_addr_list:这个是最重要的的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡,。此时可以通过此变量获取IP地址信息。 +- h_addrtype:gethostbyname 函数不仅支持 IPv4 还支持 IPv6。因此可以通过此变量获取保存在 h_addr_list 的IP地址族信息。若是 IPv4,则此变量中存有 AF_INET。 +- h_length:保存IP地址长度。若是 IPv4 地址,因为是 4 个字节,则保存 4;IPv6 时,因为是 16 个字节,故保存 16。 +- h_addr_list:这个是最重要的成员。通过此变量以整数形式保存域名相对应的IP地址。另外,用户比较多的网站有可能分配多个IP地址给同一个域名,利用多个服务器做负载均衡。此时可以通过此变量获取IP地址信息。 调用 gethostbyname 函数后,返回的结构体变量如图所示: @@ -109,9 +109,9 @@ inet_ntoa(*(struct in_addr *)host->h_addr_list[i]) struct hostent *gethostbyaddr(const char *addr, socklen_t len, int family); /* 成功时返回 hostent 结构体变量地址值,失败时返回 NULL 指针 -addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPV4 地址之外的全部信息,该变量的类型声明为 char 指针 -len: 向第一个参数传递的地址信息的字节数,IPV4时为 4 ,IPV6 时为16. -family: 传递地址族信息,ipv4 是 AF_INET ,IPV6是 AF_INET6 +addr: 含有IP地址信息的 in_addr 结构体指针。为了同时传递 IPv4 地址之外的全部信息,该变量的类型声明为 char 指针。 +len: 向第一个参数传递的地址信息的字节数,IPv4 时为 4,IPv6 时为 16。 +family: 传递地址族信息,IPv4 是 AF_INET,IPv6 是 AF_INET6。 */ ``` @@ -140,21 +140,35 @@ gcc gethostbyaddr.c -o hostaddr > 以下答案仅代表本人个人观点,可能不是正确答案。 -1. **下列关于DNS的说法错误的是?** +1. **下列关于 DNS 的说法错误的是?** - 答:字体加粗的表示正确答案。 + 答:说法错误的是选项 2 和 4。 - 1. **因为DNS从存在,故可以使用域名代替IP** - 2. DNS服务器实际上是路由器,因为路由器根据域名决定数据的路径 - 3. **所有域名信息并非集中与 1 台 DNS 服务器,但可以获取某一 DNS 服务器中未注册的所有地址** - 4. DNS 服务器根据操作系统进行区分,Windows 下的 DNS 服务器和 Linux 下的 DNS 服务器是不同的。 + 1. 因为 DNS 的存在,故可以使用域名代替 IP。(正确) + 2. DNS 服务器实际上是路由器,因为路由器根据域名决定数据的路径。(**错误**:DNS 服务器不是路由器,路由器是根据 IP 地址而非域名来决定数据转发路径的) + 3. 所有域名信息并非集中于 1 台 DNS 服务器,但可以获取某一 DNS 服务器中未注册的所有地址。(正确:通过 DNS 的层次化结构,可以逐级查询获取未在本地 DNS 服务器中注册的地址) + 4. DNS 服务器根据操作系统进行区分,Windows 下的 DNS 服务器和 Linux 下的 DNS 服务器是不同的。(**错误**:DNS 协议是标准化的,与操作系统无关) 2. **阅读如下对话,并说明东秀的方案是否可行。(因为对话的字太多,用图代替)** ![](https://i.loli.net/2019/01/18/5c41a22f35390.png) - 答:答案就是可行,DNS 服务器是分布式的,一台坏了可以找其他的。 + 答:东秀的方案是可行的。DNS 服务器采用分布式层次结构,具有冗余性和容错性。当一台 DNS 服务器故障时,可以自动切换到其他可用的 DNS 服务器进行查询,不会导致整个域名解析系统瘫痪。此外,DNS 解析结果通常会在本地缓存一段时间,即使 DNS 服务器暂时不可用,已缓存的解析记录仍然可以正常使用。 -3. **在浏览器地址输入 www.orentec.co.kr ,并整理出主页显示过程。假设浏览器访问默认 DNS 服务器中并没有关于 www.orentec.co.kr 的地址信息.** +3. **在浏览器地址输入 www.orentec.co.kr,并整理出主页显示过程。假设浏览器访问默认 DNS 服务器中并没有关于 www.orentec.co.kr 的地址信息。** - 答:可以参考一下知乎回答,[在浏览器地址栏输入一个URL后回车,背后会进行哪些技术步骤?](https://www.zhihu.com/question/34873227/answer/518086565),我用我自己的理解,简单说一下,首先会去向上一级的 DNS 服务器去查询,通过这种方式逐级向上传递信息,一直到达根服务器时,它知道应该向哪个 DNS 服务器发起询问。向下传递解析请求,得到IP地址候原路返回,最后会将解析的IP地址传递到发起请求的主机。 + 答:完整的域名解析过程如下: + + 1. **本地缓存查询**:浏览器首先检查本地缓存和操作系统的 hosts 文件,如果找到对应 IP 地址则直接使用。 + 2. **向本地 DNS 服务器发起查询**:如果没有找到缓存,浏览器向系统配置的本地 DNS 服务器发起递归查询请求。 + 3. **本地 DNS 服务器迭代查询**: + - 本地 DNS 服务器首先检查自己的缓存,若没有则向根域名服务器(Root Server,".")发起查询。 + - 根域名服务器返回负责 .kr 域的顶级域名服务器(TLD Server)地址。 + - 本地 DNS 服务器向 .kr 的顶级域名服务器发起查询。 + - 顶级域名服务器返回负责 orentec.co.kr 的权威域名服务器地址。 + - 本地 DNS 服务器向权威域名服务器发起查询。 + - 权威域名服务器返回 www.orentec.co.kr 的 IP 地址。 + 4. **返回结果**:本地 DNS 服务器将解析到的 IP 地址返回给浏览器,同时在本地缓存该结果。 + 5. **建立连接**:浏览器使用获得的 IP 地址与目标服务器建立 TCP 连接,发送 HTTP 请求,获取并渲染网页内容。 + + 可以参考知乎回答:[在浏览器地址栏输入一个 URL 后回车,背后会进行哪些技术步骤?](https://www.zhihu.com/question/34873227/answer/518086565) diff --git a/ch09/README.md b/ch09/README.md index 2e567b9..4b8a1c6 100644 --- a/ch09/README.md +++ b/ch09/README.md @@ -52,11 +52,11 @@ sock: 用于查看选项套接字文件描述符 level: 要查看的可选项协议层 optname: 要查看的可选项名 optval: 保存查看结果的缓冲地址值 -optlen: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信息的字节数。 +optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。 */ ``` -上述函数可以用来读取套接字可选项,下面的函数可以更改可选项:d +上述函数可以用来读取套接字可选项,下面的函数可以更改可选项: ```c #include @@ -68,7 +68,7 @@ sock: 用于更改选项套接字文件描述符 level: 要更改的可选项协议层 optname: 要更改的可选项名 optval: 保存更改结果的缓冲地址值 -optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。 +optlen: 向第四个参数传递的缓冲大小值(选项值的长度)。 */ ``` @@ -127,7 +127,7 @@ Output buffer size: 16384 编译运行: ```shell -gcc get_buf.c -o setbuf +gcc set_buf.c -o setbuf ./setbuf ``` @@ -170,7 +170,7 @@ Output buffer size: 6144 #### 9.2.3 地址再分配 -Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。 +Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务器以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。 ![](https://i.loli.net/2019/01/19/5c42dec2ba42b.png) @@ -200,7 +200,7 @@ setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen); TCP 套接字默认使用 `Nagle` 算法交换数据,因此最大限度的进行缓冲,直到收到 ACK 。左图也就是说一共传递 4 个数据包以传输一个字符串。从右图可以看出,发送数据包一共使用了 10 个数据包。由此可知,不使用 `Nagle` 算法将对网络流量产生负面影响。即使只传输一个字节的数据,其头信息都可能是几十个字节。因此,为了提高网络传输效率,必须使用 `Nagle` 算法。 - `Nagle` 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 `Nagle` 算法要比使用它时传输速度快。最典型的就是「传输大文数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 `Nagle` 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。 + `Nagle` 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 `Nagle` 算法要比使用它时传输速度快。最典型的就是「传输大文件数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 `Nagle` 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。 所以,未准确判断数据性质时不应禁用 `Nagle` 算法。 @@ -234,12 +234,12 @@ getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, &opt_len); 1. **下列关于 Time-wait 状态的说法错误的是?** - 答:以下字体加粗的代表正确。 + 答:错误的说法是第 1、3、4 项。正确的说法是第 2 项(加粗显示)。 - 1. Time-wait 状态只在服务器的套接字中发生 - 2. **断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。** - 3. Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关 - 4. Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生 + 1. ~~Time-wait 状态只在服务器的套接字中发生~~(错误:客户端先断开连接时也会进入 Time-wait 状态) + 2. **断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。**(正确) + 3. ~~Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关~~(错误:Time-wait 状态与断开连接的四次握手过程直接相关) + 4. ~~Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生~~(错误:Time-wait 状态对于保证 TCP 连接可靠关闭是必要的,但在某些紧急重启场景下可通过 SO_REUSEADDR 重用端口) 2. **TCP_NODELAY 可选项与 Nagle 算法有关,可通过它禁用 Nagle 算法。请问何时应考虑禁用 Nagle 算法?结合收发数据的特性给出说明。** diff --git a/ch10/README.md b/ch10/README.md index 93d606b..c1f7077 100644 --- a/ch10/README.md +++ b/ch10/README.md @@ -12,7 +12,7 @@ - 多路复用服务器:通过捆绑并统一管理 I/O 对象提供服务 - 多线程服务器:通过生成与客户端等量的线程提供服务 -先是第一种方法:多进程服务器 +先介绍第一种方法:多进程服务器。 #### 10.1.2 理解进程 @@ -20,31 +20,31 @@ > 占用内存空间的正在运行的程序 -假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这是才可称为进程。 +假如你下载了一个游戏到电脑上,此时的游戏不是进程,而是程序。只有当游戏被加载到主内存并进入运行状态,这时才可称为进程。 -#### 10.1.3 进程 ID +#### 10.1.3 进程 ID -在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程ID」,其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1 。接下来观察在 Linux 中运行的进程。 +在介绍进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程 ID」,其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1。接下来观察在 Linux 中运行的进程。 ```shell ps au ``` -通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程ID)。参数 a 和 u列出了所有进程的详细信息。 +通过上面的命令可查看当前运行的所有进程。需要注意的是,该命令同时列出了 PID(进程 ID)。参数 a 和 u 列出了所有进程的详细信息。 ![](https://i.loli.net/2019/01/20/5c43d7c1f2a8b.png) #### 10.1.4 通过调用 fork 函数创建进程 -创建进程的方式很多,此处只介绍用于创建多进程服务端的 fork 函数。 +创建进程的方式很多,此处只介绍用于创建多进程服务器的 fork 函数。 ```c #include pid_t fork(void); -// 成功时返回进程ID,失败时返回 -1 +// 成功时返回进程ID,失败时返回 -1 ``` -fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确的说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。 +fork 函数将创建调用的进程副本。也就是说,并非根据完全不同的程序创建进程,而是复制正在运行的、调用 fork 函数的进程。另外,两个进程都执行 fork 函数调用后的语句(准确地说是在 fork 函数返回后)。但因为是通过同一个进程、复制相同的内存空间,之后的程序流要根据 fork 函数的返回值加以区分。即利用 fork 函数的如下特点区分程序执行流程。 - 父进程:fork 函数返回子进程 ID - 子进程:fork 函数返回 0 @@ -53,7 +53,7 @@ fork 函数将创建调用的进程副本。也就是说,并非根据完全不 ![](https://i.loli.net/2019/01/20/5c43da5412b90.png) -从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程的 lval 的值增加 1 ,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval 。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子: +从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回值区分父子进程。父进程的 lval 的值增加 1,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子: - [fork.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch10/fork.c) @@ -90,7 +90,7 @@ gcc fork.c -o fork ![](https://i.loli.net/2019/01/20/5c43e054e7f6f.png) -可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码,对于父进程来说,fork 函数返回的是子进程的ID,对于子进程来说,fork 函数返回 0。所以这两个变量,父进程进行了 +2 操作 ,而子进程进行了 -2 操作,所以结果是这样。 +可以看出,当执行了 fork 函数之后,此后就相当于有了两个程序在执行代码。对于父进程来说,fork 函数返回的是子进程的 ID,对于子进程来说,fork 函数返回 0。在 fork 之后,父进程对两个变量进行了 -2 操作,而子进程对两个变量进行了 +2 操作,所以结果是这样。 ### 10.2 进程和僵尸进程 @@ -147,7 +147,7 @@ int main(int argc, char *argv[]) sleep(30); } if (pid == 0) - puts("End child proess"); + puts("End child process"); else puts("End parent process"); return 0; @@ -293,7 +293,7 @@ int main(int argc, char *argv[]) } else { - //调用waitpid 传递参数 WNOHANG ,这样之前有没有终止的子进程则返回0 + // 调用 waitpid 传递参数 WNOHANG,这样如果没有终止的子进程则返回 0 while (!waitpid(-1, &status, WNOHANG)) { sleep(3); @@ -388,7 +388,7 @@ unsigned int alarm(unsigned int seconds); // 返回0或以秒为单位的距 SIGALRM 信号发生所剩时间 ``` -如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递为 0 ,则之前对 SIGALRM 信号的预约将取消。如果通过改函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。 +如果调用该函数的同时向它传递一个正整型参数,相应时间后(以秒为单位)将产生 SIGALRM 信号。若向该函数传递 0,则之前对 SIGALRM 信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用 signal 函数)终止进程,不做任何处理。 - [signal.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch10/signal.c) @@ -448,11 +448,11 @@ gcc signal.c -o signal #### 10.3.3 利用 sigaction 函数进行信号处理 -前面所学的内容可以防止僵尸进程,还有一个函数,叫做 sigaction 函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为: +前面所学的内容可以防止僵尸进程,还有一个函数叫做 sigaction 函数,它类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为: > signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同 -实际上现在很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容,下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。 +实际上现在很少用 signal 函数编写程序,它只是为了保持对旧程序的兼容。下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。 ```c #include @@ -627,7 +627,7 @@ Child send: 24 wait ``` -请自习观察结果,结果中的每一个空行代表间隔了5 秒,程序是先创建了两个子进程,然后子进程 10 秒之后会返回值,第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 +请仔细观察结果,结果中的每一个空行代表间隔了 5 秒。程序先创建了两个子进程,子进程在 10 秒之后会返回值。第一个 wait 由于子进程在执行,所以直接被唤醒,然后这两个子进程正在睡眠 10 秒,所以 5 秒之后第二个 wait 开始执行,又过了 5 秒,两个子进程同时被唤醒。所以剩下的 wait 也被唤醒。 所以在本程序的过程中,当子进程终止时候,会向系统发送一个信号,然后调用我们提前写好的处理函数,在处理函数中使用 waitpid 来处理僵尸进程,获取子进程返回值。 @@ -635,7 +635,7 @@ wait #### 10.4.1 基于进程的并发服务器模型 -之前的回声服务器每次只能同事向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。 +之前的回声服务器每次只能同时向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。 ![](https://i.loli.net/2019/01/21/5c453664cde26.png) @@ -719,27 +719,27 @@ gcc echo_mpclient.c -o eclient ### 10.6 习题 -> 以下答案仅代表本人个人观点,可能不是正确答案。 +> 以下答案仅代表本人个人观点,可能不是标准答案。 1. **下列关于进程的说法错误的是?** - 答:以下加粗的内容为正确的 + 答:说法 3 和说法 4 是错误的。 - 1. **从操作系统的角度上说,进程是程序运行的单位** - 2. 进程根据创建方式建立父子关系 - 3. **进程可以包含其他进程,即一个进程的内存空间可以包含其他进程** - 4. **子进程可以创建其他子进程,而创建出来的子进程还可以创建其他子进程,但所有这些进程只与一个父进程建立父子关系。** + - 说法 1:从操作系统的角度上说,进程是程序运行的单位。(正确) + - 说法 2:进程根据创建方式建立父子关系。(正确) + - 说法 3:**进程可以包含其他进程,即一个进程的内存空间可以包含其他进程。**(错误)进程拥有独立的内存空间,一个进程不能直接包含另一个进程的内存空间。 + - 说法 4:**子进程可以创建其他子进程,而创建出来的子进程还可以创建其他子进程,但所有这些进程只与一个父进程建立父子关系。**(错误)一个进程可以有多个子进程,但每个子进程只有一个父进程。子进程创建的孙进程与原父进程没有直接的父子关系。 -2. **调用 fork 函数将创建子进程,一下关于子进程错误的是?** +2. **调用 fork 函数将创建子进程,以下关于子进程错误的是?** - 答:以下加粗的内容为正确的 + 答:说法 1、说法 3 和说法 4 是错误的。 - 1. **父进程销毁时也会同时销毁子进程** - 2. **子进程是复制父进程所有资源创建出的进程** - 3. 父子进程共享全局变量 - 4. 通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。 + - 说法 1:**父进程销毁时也会同时销毁子进程。**(错误)父进程销毁时,子进程不会自动销毁,而是成为孤儿进程,被 init 进程(PID 为 1)收养。 + - 说法 2:子进程是复制父进程所有资源创建出的进程。(正确) + - 说法 3:**父子进程共享全局变量。**(错误)fork 之后,父子进程拥有各自独立的全局变量副本,修改其中一个不会影响另一个。 + - 说法 4:**通过 fork 函数创建的子进程将执行从开始到 fork 函数调用为止的代码。**(错误)子进程从 fork 函数调用后的语句开始执行,不会重新执行之前的代码。 -3. **创建子进程时复制父进程所有内容,此时复制对象也包含套接字文件描述符。编写程序验证赋值的文件描述符整数值是否与原文件描述符数值相同。** +3. **创建子进程时复制父进程所有内容,此时复制对象也包含套接字文件描述符。编写程序验证复制的文件描述符整数值是否与原文件描述符数值相同。** 答:代码为多进程服务器修改而来,代码:[test_server.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch10/test_server.c) @@ -747,8 +747,17 @@ gcc echo_mpclient.c -o eclient ![](https://s2.ax1x.com/2019/01/21/kPj3Md.png) - 从图上可以看出,数值相同。 + 从图上可以看出,数值相同。fork 复制文件描述符时,子进程获得的文件描述符整数值与父进程的相同。 4. **请说明进程变为僵尸进程的过程以及预防措施。** - 答:当一个父进程以fork()系统调用建立一个新的子进程后,核心进程就会在进程表中给这个子进程分配一个进入点,然后将相关信息存储在该进入点所对应的进程表内。这些信息中有一项是其父进程的识别码。而当这个子进程结束的时候(比如调用exit命令结束),其实他并没有真正的被销毁,而是留下一个称为僵尸进程(Zombie)的数据结构(系统调用exit的作用是使进程退出,但是也仅仅限于一个正常的进程变成了一个僵尸进程,并不能完全将其销毁)。**预防措施**:通过 wait 和 waitpid 函数加上信号函数写代码来预防。 + 答:当一个父进程通过 fork() 系统调用创建一个新的子进程后,操作系统会在进程表中给这个子进程分配一个表项,并将相关信息(包括父进程的 PID)存储在该表项中。 + + 当子进程结束时(比如调用 exit() 函数或 main 函数 return),它并不会立即被完全销毁,而是保留一个称为「僵尸进程」的数据结构在进程表中。这个僵尸进程包含着子进程的退出状态信息,等待父进程读取。系统调用 exit 的作用是使进程退出,但这只是将进程转变为僵尸状态,并不能完全将其销毁。 + + **僵尸进程的产生条件**:子进程先于父进程终止,而父进程没有调用 wait() 或 waitpid() 来回收子进程的退出状态。 + + **预防措施**: + - 在父进程中调用 wait() 或 waitpid() 函数来回收子进程的退出状态 + - 使用信号处理机制:注册 SIGCHLD 信号处理函数,当子进程终止时,操作系统会向父进程发送 SIGCHLD 信号,在信号处理函数中调用 waitpid() 来回收僵尸进程 + - 如果父进程不需要关心子进程的退出状态,可以显式忽略 SIGCHLD 信号(通过 signal(SIGCHLD, SIG_IGN)),这样子进程终止后会被系统自动回收 diff --git a/ch11/README.md b/ch11/README.md index 3402291..e282864 100644 --- a/ch11/README.md +++ b/ch11/README.md @@ -113,7 +113,6 @@ int main(int argc, char *argv[]) } return 0; } - ``` 编译运行: @@ -130,7 +129,7 @@ Parent proc output: Who are you? Child proc output: Thank you for your message ``` -运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。 +运行结果是正确的,但是如果注释掉代码中子进程里的 `sleep(2);`(第18行),就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。因为,注释掉 `sleep(2);` 会产生问题。子进程可能读回自己向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。 当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动,过程如下图所示: @@ -198,7 +197,7 @@ gcc echo_storeserv.c -o serv ![](https://s2.ax1x.com/2019/01/22/kFUAHS.png) -从图上可以看出,服务端已经生成了文件,把客户端的消息保存可下来,只保存了10次消息。 +从图上可以看出,服务端已经生成了文件,把客户端的消息保存了下来,只保存了10次消息。 ### 11.3 习题 @@ -206,11 +205,11 @@ gcc echo_storeserv.c -o serv 1. **什么是进程间通信?分别从概念和内存的角度进行说明。** - 答:进程间通信意味着两个不同的进程间可以交换数据。从内存上来说,就是两个进程可以访问同一个内存区域,然后通过这个内存区域数据的变化来进行通信。 + 答:进程间通信(Inter-Process Communication,IPC)是指两个不同的进程间可以交换数据的机制。从概念上讲,它允许独立运行的进程之间传递信息、同步操作。从内存角度来说,由于每个进程都有自己独立的内存地址空间,进程间无法直接访问彼此的内存,因此需要操作系统提供特殊的共享内存区域或通信机制(如管道、共享内存、消息队列等),通过这个内核提供的缓冲区来进行数据交换。 2. **进程间通信需要特殊的 IPC 机制,这是由于操作系统提供的。进程间通信时为何需要操作系统的帮助?** - 答:为了进行进程间通信,需要管道的帮助,但是管道不是进程的资源,它属于从操作系统,所以,两个进程通过操作系统提供的内存空间进行通信。 + 答:进程间通信需要操作系统的帮助是因为:每个进程都有独立的内存地址空间,进程之间无法直接访问对方的内存。IPC 机制(如管道)属于操作系统内核管理的资源,而非单个进程的资源。通过 fork 函数复制的是文件描述符,而非管道本身。因此,两个进程必须通过操作系统提供的内核缓冲区来实现数据交换。 3. **「管道」是典型的 IPC 技法。关于管道,请回答以下问题:** @@ -224,4 +223,4 @@ gcc echo_storeserv.c -o serv 3. **管道允许 2 个进程间的双向通信。双向通信中需要注意哪些内容?** - 答:向管道传输数据时,先读的进程会把数据取走。简言之,就是数据进入管道候会变成无主数据,所以有时候为了防止错误,需要多个管道来进程通信。 + 答:向管道传输数据时,先调用 read 函数的进程会把数据取走。换言之,数据进入管道后会变成无主数据,任何连接到该管道的进程都可以读取。因此,在使用单个管道进行双向通信时,一个进程可能会读取到自己写入的数据,导致另一个进程无限等待。为了避免这个问题,通常需要创建两个管道,各自负责不同方向的数据流动。 diff --git a/ch12/README.md b/ch12/README.md index a720997..e66407c 100644 --- a/ch12/README.md +++ b/ch12/README.md @@ -24,7 +24,7 @@ I/O 复用技术可以解决这个问题。 ![](https://s2.ax1x.com/2019/01/23/kA8H81.png) -上图是一个纸杯电话系统,为了使得三人同时通话,说话时要同事对着两个纸杯,接听时也需要耳朵同时对准两个纸杯。为了完成 3 人通话,可以进行如下图的改进: +上图是一个纸杯电话系统,为了使得三人同时通话,说话时要同时对着两个纸杯,接听时也需要耳朵同时对准两个纸杯。为了完成 3 人通话,可以进行如下图的改进: ![](https://s2.ax1x.com/2019/01/23/kA8bgx.png) @@ -107,7 +107,7 @@ readset: 将所有关注「是否存在待读取数据」的文件描述符注 writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。 exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。 timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息 -返回值: 发生错误时返回 -1,超时时返回0,。因发生关注的时间返回时,返回大于0的值,该值是发生事件的文件描述符数。 +返回值: 发生错误时返回 -1,超时时返回 0。因发生关注的事件返回时,返回大于 0 的值,该值是发生事件的文件描述符数。 */ ``` @@ -128,7 +128,7 @@ struct timeval }; ``` -本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微妙数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。因此,可以通过返回值了解原因。如果不想设置超时,则传递 NULL 参数。 +本来 select 函数只有在监视文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过上述结构体变量,将秒数填入 tv_sec 的成员,将微秒数填入 tv_usec 的成员,然后将结构体的地址值传递到 select 函数的最后一个参数。此时,即使文件描述符未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select 函数返回 0。因此,可以通过返回值了解原因。如果不想设置超时,则传递 NULL 参数。 #### 12.2.4 调用 select 函数查看结果 @@ -186,7 +186,7 @@ gcc echo_selectserv.c -o selserv 1. **请解释复用技术的通用含义,并说明何为 I/O 复用。** - 答:通用含义:在 1 个通信频道中传递多个数据(信号)的技术。IO复用就是进程预先告诉内核需要监视的IO条件,使得内核一旦发现进程指定的一个或多个IO条件就绪,就通过进程进程处理,从而不会在单个IO上阻塞了。 + 答:通用含义:在 1 个通信频道中传递多个数据(信号)的技术。I/O 复用是指进程预先告诉内核需要监视的 I/O 条件,使得内核一旦发现进程指定的一个或多个 I/O 条件就绪(例如套接字变为可读或可写),就通知进程进行处理,从而进程不会在单个 I/O 操作上阻塞。 参考文章:[Linux网络编程-IO复用技术](https://www.cnblogs.com/luoxn28/p/6220372.html) @@ -194,15 +194,15 @@ gcc echo_selectserv.c -o selserv 答:多进程需要进行大量的运算和大量的内存空间。在 I/O 复用服务器中通过 select 函数监视文件描述符,通过判断变化的文件描述符,来得知变化的套接字是哪个,从而实时应答来自多个客户端的请求。 -3. **复用服务器端需要 select 函数。下列关于 select 函数使用方法的描述错误的是?** +3. **复用服务器端需要 select 函数。下列关于 select 函数使用方法的描述错误的是?** - 答:以下加粗的为正确的描述。 + 答:错误的描述是 2 和 3。说明如下: - 1. 调用 select 函数前需要集中 I/O 监视对象的文件描述符 - 2. **若已通过 select 函数注册为监视对象,则后续调用 select 函数时无需重复注册** - 3. 复用服务器端同一时间只能服务于 1 个客户端,因此,需要服务的客户端接入服务器端后只能等待 - 4. **与多线程服务端不同,基于 select 的复用服务器只需要 1 个进程。因此,可以减少因创建多进程产生的服务器端的负担**。 + - 描述 1 正确:调用 select 函数前需要集中 I/O 监视对象的文件描述符。 + - 描述 2 错误:**每次调用 select 函数时都需要重新注册监视对象**,因为 select 函数调用后,fd_set 变量会被修改,只保留发生变化的文件描述符位为 1。 + - 描述 3 错误:**复用服务器端可以同时服务于多个客户端**,这正是 I/O 复用的核心优势——通过 select 函数同时监视多个套接字,哪个就绪就处理哪个。 + - 描述 4 正确:与多进程/多线程服务端不同,基于 select 的复用服务器只需要 1 个进程,可以减少因创建多进程产生的服务器端的负担。 4. **select 函数的观察对象中应包含服务端套接字(监听套接字),那么应将其包含到哪一类监听对象集合?请说明原因**。 - 答:应该包含到「是否存在待读取数据」,因为服务器端需要查看套接字中有没有可以读取的数据。 + 答:应该包含到「是否存在待读取数据」(即 readset)集合中。原因是:服务端套接字(监听套接字)的作用是监听客户端的连接请求,当有新的客户端发起连接时,监听套接字变为「可读」状态,此时 accept 函数不会阻塞,可以成功完成连接。因此需要监视监听套接字是否可读,以得知是否有新的连接请求到来。 diff --git a/ch13/README.md b/ch13/README.md index d8236db..a2d4c05 100644 --- a/ch13/README.md +++ b/ch13/README.md @@ -6,7 +6,7 @@ #### 13.1.1 Linux 中的 send & recv -首先看 sned 函数定义: +首先看 send 函数定义: ```c #include @@ -27,8 +27,8 @@ flags: 传输数据时指定的可选项信息 ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags); /* 成功时返回接收的字节数(收到 EOF 返回 0),失败时返回 -1 -sockfd: 表示数据接受对象的连接的套接字文件描述符 -buf: 保存接受数据的缓冲地址值 +sockfd: 表示数据接收对象的连接的套接字文件描述符 +buf: 保存接收数据的缓冲地址值 nbytes: 可接收的最大字节数 flags: 接收数据时指定的可选项参数 */ @@ -41,7 +41,7 @@ send & recv 函数的可选项意义: | 可选项(Option) | 含义 | send | recv | | ---------------- | ------------------------------------------------------------ | ---- | ---- | | MSG_OOB | 用于传输带外数据(Out-of-band data) | O | O | -| MSG_PEEK | 验证输入缓冲中是否存在接受的数据 | X | O | +| MSG_PEEK | 验证输入缓冲中是否存在接收的数据 | X | O | | MSG_DONTROUTE | 数据传输过程中不参照本地路由(Routing)表,在本地(Local)网络中寻找目的地 | O | X | | MSG_DONTWAIT | 调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/O | O | O | | MSG_WAITALL | 防止函数返回,直到接收到全部请求的字节数 | X | O | @@ -121,14 +121,14 @@ TCP 数据包实际包含更多信息。TCP 头部包含如下两种信息: #### 13.1.4 检查输入缓冲 -同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。因此,该选项通常与 MSG_DONTWAIT 合作,用于以非阻塞方式验证待读数据存在与否。下面的示例是二者的含义: +同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。因此,该选项通常与 MSG_DONTWAIT 配合,用于以非阻塞方式验证待读数据存在与否。下面的示例是二者的含义: - [peek_recv.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch13/peek_recv.c) - [peek_send.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch13/peek_send.c) 编译运行: -``` +```shell gcc peek_recv.c -o recv gcc peek_send.c -o send ./recv 9190 @@ -156,7 +156,7 @@ readv & writev 函数的功能可概括如下: ssize_t writev(int filedes, const struct iovec *iov, int iovcnt); /* 成功时返回发送的字节数,失败时返回 -1 -filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符. +filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向其传递文件或标准输出描述符. iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息 iovcnt: 向第二个参数传递数组长度 */ @@ -208,7 +208,7 @@ int main(int argc, char *argv[]) ```shell gcc writev.c -o writev -./writevi +./writev ``` 结果: @@ -222,10 +222,10 @@ Write bytes: 7 ```c #include -ssize_t readv(int filedes, const struct iovc *iov, int iovcnt); +ssize_t readv(int filedes, const struct iovec *iov, int iovcnt); /* 成功时返回接收的字节数,失败时返回 -1 -filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 write 一样向向其传递文件或标准输出描述符. +filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 write 一样向其传递文件或标准输出描述符. iov: iovec 结构体数组的地址值,结构体 iovec 中包含待数据保存的位置和大小信息 iovcnt: 第二个参数中数组的长度 */ @@ -288,12 +288,63 @@ gcc readv.c -o rv ### 13.3 基于 Windows 的实现 -暂略 +Windows 下的 Winsock 提供了与 Linux 类似的 I/O 函数,主要有以下区别: + +#### 13.3.1 send 和 recv 函数 + +Winsock 中的 send 和 recv 函数原型与 Linux 基本一致: + +```c +#include +int send(SOCKET s, const char *buf, int len, int flags); +int recv(SOCKET s, char *buf, int len, int flags); +``` + +主要区别: +- 参数类型:Linux 使用 `int sockfd`,Windows 使用 `SOCKET s`(实际是 `typedef UINT_PTR SOCKET;`) +- 缓冲区类型:Linux 使用 `void *`,Windows 使用 `char *` +- 返回值:Linux 返回 `ssize_t`,Windows 返回 `int`(失败时返回 `SOCKET_ERROR`,即 -1) + +#### 13.3.2 WSASend、WSARecv 和 WSARecvEx + +Windows 还提供了扩展版本的异步 I/O 函数: + +```c +int WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, + LPDWORD lpNumberOfBytesSent, DWORD dwFlags, + LPWSAOVERLAPPED lpOverlapped, + LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); + +int WSARecv(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, + LPDWORD lpNumberOfBytesRecvd, LPDWORD lpFlags, + LPWSAOVERLAPPED lpOverlapped, + LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine); +``` + +这些函数支持重叠 I/O(Overlapped I/O)和完成端口(Completion Port)模型,适合高性能服务器开发。 + +#### 13.3.3 可选项差异 + +Windows 与 Linux 在可选项上存在一些差异: + +| 可选项 | Linux | Windows | +| ------ | ----- | ------- | +| MSG_OOB | 支持 | 支持 | +| MSG_PEEK | 支持 | 支持 | +| MSG_DONTWAIT | 支持 | 不支持(需通过 ioctlsocket 设置非阻塞模式) | +| MSG_WAITALL | 支持 | 支持 | +| MSG_PARTIAL | 不支持 | 支持(仅用于流式套接字) | + +Windows 不支持 MSG_DONTWAIT,需要通过 `ioctlsocket` 函数设置套接字为非阻塞模式: + +```c +u_long mode = 1; +ioctlsocket(sock, FIONBIO, &mode); +``` ### 13.4 习题 > 以下答案仅代表本人个人观点,可能不是正确答案。 -> 1. **下列关于 MSG_OOB 可选项的说法错误的是**? @@ -308,6 +359,25 @@ gcc readv.c -o rv 答:需要传输的数据分别位于不同缓冲(数组)时,需要多次调用 write 函数。此时可通过 1 次 writev 函数调用替代操作,当然会提高效率。同样,需要将输入缓冲中的数据读入不同位置时,可以不必多次调用 read 函数,而是利用 1 次 readv 函数就能大大提高效率。 + 从 I/O 缓冲的角度来看,writev 函数可以将分散的数据整合为一次系统调用,减少用户态与内核态之间的上下文切换次数,同时减少网络数据包的个数(尤其在禁用 Nagle 算法时效果更明显)。 + 3. **通过 recv 函数验证输入缓冲中是否存在数据时(确认后立即返回时),如何设置 recv 函数最后一个参数中的可选项?分别说明各可选项的含义**。 - 答:使用 MSG_PEEK 来验证输入缓冲中是否存在待接收的数据。各个可选项的意义参见上面对应章节的表格。 + 答:应同时设置 `MSG_PEEK` 和 `MSG_DONTWAIT` 两个可选项。 + + 各可选项的含义: + + - **MSG_PEEK**:验证输入缓冲中是否存在待接收的数据。设置此选项后,recv 函数会读取输入缓冲中的数据但不会将其删除(数据仍保留在缓冲中),可以再次读取。 + - **MSG_DONTWAIT**:调用 I/O 函数时不阻塞,用于非阻塞(Non-blocking)I/O。设置此选项后,如果输入缓冲中没有数据,recv 函数会立即返回错误(errno 设为 EAGAIN 或 EWOULDBLOCK),而不是阻塞等待。 + + 示例代码: + ```c + int len = recv(sockfd, buf, sizeof(buf), MSG_PEEK | MSG_DONTWAIT); + if (len > 0) { + // 缓冲中有数据 + } else if (len == 0) { + // 连接已关闭 + } else { + // 无数据或出错 + } + ``` diff --git a/ch14/README.md b/ch14/README.md index 3788d1f..fbef5b4 100644 --- a/ch14/README.md +++ b/ch14/README.md @@ -39,7 +39,7 @@ int send_sock; int time_live = 64; ... send_sock=socket(PF_INET,SOCK_DGRAM,0); -setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live,sizeof(time_live); +setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live,sizeof(time_live)); ... ``` @@ -53,7 +53,7 @@ recv_sock=socket(PF_INET,SOCK_DGRAM,0); ... join_adr.imr_multiaddr.s_addr="多播组地址信息"; join_adr.imr_interface.s_addr="加入多播组的主机地址信息"; -setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(join_adr); +setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(join_adr)); ... ``` @@ -71,7 +71,7 @@ struct ip_mreq 多播中用「发送者」(以下称为 Sender) 和「接收者」(以下称为 Receiver)替代服务器端和客户端。顾名思义,此处的 Sender 是多播数据的发送主体,Receiver 是需要多播组加入过程的数据接收主体。下面是示例,示例的运行场景如下: -- Sender : 向 AAA 组广播(Broadcasting)文件中保存的新闻信息 +- Sender : 向 AAA 组多播(Multicasting)文件中保存的新闻信息 - Receiver : 接收传递到 AAA 组的新闻信息。 下面是两个代码: @@ -143,7 +143,67 @@ gcc news_sender_brd.c -o sender ### 14.3 基于 Windows 的实现 -暂略 +Windows 平台下的多播和广播实现与 Linux 类似,主要区别在于套接字操作相关的头文件和函数调用。 + +#### 14.3.1 Windows 多播实现 + +在 Windows 中实现多播 Sender 时,需要包含 `winsock2.h` 头文件,并链接 `ws2_32.lib` 库。主要代码区别如下: + +```c +#include +#include +#pragma comment(lib, "ws2_32.lib") + +// Windows 下的套接字初始化 +WSADATA wsaData; +if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) + error_handling("WSAStartup() error"); + +// 创建套接字 +SOCKET send_sock; +send_sock = socket(AF_INET, SOCK_DGRAM, 0); + +// 设置 TTL +int time_live = 64; +setsockopt(send_sock, IPPROTO_IP, IP_MULTICAST_TTL, (char*)&time_live, sizeof(time_live)); + +// ... 发送数据 ... + +// Windows 下的套接字关闭 +closesocket(send_sock); +WSACleanup(); +``` + +Windows 下实现多播 Receiver 时,加入多播组的代码如下: + +```c +struct ip_mreq join_adr; +join_adr.imr_multiaddr.s_addr = inet_addr(group_ip); +join_adr.imr_interface.s_addr = htonl(INADDR_ANY); +setsockopt(recv_sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char*)&join_adr, sizeof(join_adr)); +``` + +#### 14.3.2 Windows 广播实现 + +Windows 下实现广播时,启用广播的代码如下: + +```c +SOCKET send_sock; +send_sock = socket(AF_INET, SOCK_DGRAM, 0); + +int bcast = 1; +setsockopt(send_sock, SOL_SOCKET, SO_BROADCAST, (char*)&bcast, sizeof(bcast)); + +// ... 发送数据 ... +``` + +与 Linux 的主要区别总结: + +1. 头文件:Windows 使用 `winsock2.h`,Linux 使用 `sys/socket.h` 等头文件 +2. 初始化:Windows 需要调用 `WSAStartup` 初始化 Winsock,使用完后调用 `WSACleanup` +3. 套接字类型:Windows 使用 `SOCKET` 类型(实际是 `unsigned __int64`),Linux 使用 `int` +4. 关闭套接字:Windows 使用 `closesocket`,Linux 使用 `close` +5. 指针类型转换:Windows 下 `setsockopt` 的第四个参数通常转换为 `char*`,Linux 下转换为 `void*` ### 14.4 习题 @@ -159,12 +219,12 @@ gcc news_sender_brd.c -o sender 3. **下面关于多播的说法描述错误的是**? - 答:以下内容加粗的为描述正确 + 答:第 2 项描述错误。正确说明如下: - 1. 多播是用来加入多播组的所有主机传输数据的协议 - 2. 主机连接到同一网络才能加入到多播组,也就是说,多播组无法跨越多个网络 - 3. **能够加入多播组的主机数并无限制,但只能有 1个主机(Sender)向该组发送数据** - 4. **多播时使用的套接字是 UDP 套接字,因为多播是基于 UDP 进行数据通信的。** + 1. 多播是用来向加入多播组的所有主机传输数据的协议 + 2. ~~主机连接到同一网络才能加入到多播组,也就是说,多播组无法跨越多个网络~~(错误。多播可以跨越多个网络,只要路由器支持多播功能,主机就可以加入跨网络的多播组。即使路由器不支持,也可以通过隧道技术实现。) + 3. 能够加入多播组的主机数并无限制,但只能有 1 个主机(Sender)向该组发送数据 + 4. 多播时使用的套接字是 UDP 套接字,因为多播是基于 UDP 进行数据通信的 4. **多播也对网络流量有利,请比较 TCP 交换方式解释其原因** @@ -172,4 +232,4 @@ gcc news_sender_brd.c -o sender 5. **多播方式的数据通信需要 MBone 虚拟网络。换言之,MBone 是用于多播的网络,但它是虚拟网络。请解释此处的「虚拟网络」** - 答:可以理解为「通过网络中的特殊协议工作的软件概念上的网络」。也就是说, MBone 并非可以触及的物理网络。他是以物理网络为基础,通过软件方法实现的多播通信必备虚拟网络。 + 答:可以理解为「通过网络中的特殊协议工作的软件概念上的网络」。也就是说,MBone 并非可以触及的物理网络。它是以物理网络为基础,通过软件方法(隧道技术)实现的多播通信必备虚拟网络。MBone(Multicast Backbone)是互联网的多播骨干网,通过在支持多播的路由器之间建立隧道,将不支持多播的网络连接起来,从而构建一个覆盖全球的虚拟多播网络。 diff --git a/ch15/README.md b/ch15/README.md index a00ef76..b4d0a7c 100644 --- a/ch15/README.md +++ b/ch15/README.md @@ -22,7 +22,7 @@ - 传输的数据量 - 数据向输出缓冲移动的次数。 -比较 1 个字节的数据发送 10 次的情况和 10 个字节发送 1 次的情况。发送数据时,数据包中含有头信息。头信与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别: +比较 1 个字节的数据发送 10 次的情况和 10 个字节发送 1 次的情况。发送数据时,数据包中含有头信息。头信息与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别: - 1 个字节 10 次:40*10=400 字节 - 10个字节 1 次:40*1=40 字节。 @@ -37,7 +37,7 @@ - [stdcpy.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch15/stdcpy.c) -对于以上两个代码进行测试,明显基于标准 I/O 函数的代码跑的更快 +对于以上两个代码进行测试,明显基于标准 I/O 函数的代码跑的更快。这是因为标准 I/O 函数通过缓冲区减少了系统调用的次数,每次系统调用都有一定的开销(用户态与内核态的切换),而缓冲机制可以将多次小数据量的 I/O 操作合并为较少次数的系统调用,从而提高性能。 #### 15.1.3 标准 I/O 函数的几个缺点 @@ -127,7 +127,7 @@ int main() int fd = open("data.dat", O_WRONLY | O_CREAT | O_TRUNC); if (fd == -1) { - fputs("file open error"); + fputs("file open error", stdout); return -1; } @@ -168,7 +168,11 @@ gcc echo_stdserv.c -o eserver 1. **请说明标准 I/O 的 2 个优点。他为何拥有这 2 个优点?** - 答:①具有很高的移植性②有良好的缓冲提高性能。因为这些函数是由 ANSI C 标准定义的。适合所有编程领域。 + 答:①具有很高的移植性②有良好的缓冲提高性能。 + + 移植性的原因:标准 I/O 函数是由 ANSI C 标准定义的,在任何符合 ANSI C 标准的平台上都能使用,适合所有编程领域。 + + 性能的原因:标准 I/O 函数内部维护了用户态缓冲区,数据首先在缓冲区中积累,当缓冲区填满或显式刷新时才一次性调用系统函数(如 `write`)进行实际 I/O。这减少了用户态与内核态之间上下文切换的次数,从而显著提高了性能。 2. **利用标准 I/O 函数传输数据时,下面的说法是错误的**: @@ -176,4 +180,11 @@ gcc echo_stdserv.c -o eserver **为何上述说法是错误的?为达到这种效果应该添加哪些处理过程?** - 答:只是传输到了缓冲中,应该利用 fflush 来刷新缓冲区。 + 答:因为标准 I/O 函数使用缓冲机制,调用 `fputs` 后数据只是被写入到用户态的缓冲区中,而不是立即发送到套接字输出缓冲或对端主机。只有在缓冲区满、缓冲区方向改变(如从读切换到写)、文件关闭或显式刷新时,数据才会真正发送。 + + 为达到立即发送的效果,应该在调用 `fputs` 后添加 `fflush` 函数来刷新缓冲区,例如: + + ```c + fputs("Hello", fp); + fflush(fp); // 强制将缓冲区数据发送 + ``` diff --git a/ch16/README.md b/ch16/README.md index d980725..48f1813 100644 --- a/ch16/README.md +++ b/ch16/README.md @@ -6,7 +6,7 @@ 「分离 I/O 流」是一种常用表达。有 I/O 工具可区分二者,无论采用哪种方法,都可以认为是分离了 I/O 流。 -#### 16.1.1 2次 I/O 流分离 +#### 16.1.1 2 次 I/O 流分离 之前有两种分离方法: @@ -58,7 +58,7 @@ gcc sep_serv.c -o serv 原因是:服务端代码的 `fclose(writefp);` 这一句,完全关闭了套接字而不是半关闭。这才是这一章需要解决的问题。 -### 16.2 文件描述符的的复制和半关闭 +### 16.2 文件描述符的复制和半关闭 #### 16.2.1 终止「流」时无法半关闭原因 @@ -76,17 +76,17 @@ gcc sep_serv.c -o serv 只需要创建 FILE 指针前先复制文件描述符即可。复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式的 FILE 指针和写模式的 FILE 指针。这就为半关闭创造好了环境,因为套接字和文件描述符具有如下关系: -> 销毁所有文件描述符候才能销毁套接字 +> 销毁所有文件描述符后才能销毁套接字 也就是说,针对写模式 FILE 指针调用 fclose 函数时,只能销毁与该 FILE 指针相关的文件描述符,无法销毁套接字,如下图: ![](https://i.loli.net/2019/01/30/5c5123ad7df31.png) -那么调用 fclose 函数候还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O 。因此,不但没有发送 EOF ,而且仍然可以利用文件描述符进行输出。 +那么调用 fclose 函数后还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O。因此,不但没有发送 EOF,而且仍然可以利用文件描述符进行输出。 #### 16.2.2 复制文件描述符 -与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对完成描述符的复制。如图: +与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对文件描述符的复制。如图: ![](https://i.loli.net/2019/01/30/5c512579c45b6.png) @@ -98,12 +98,12 @@ gcc sep_serv.c -o serv ```c #include -int dup(int fildes); -int dup2(int fildes, int fildes2); +int dup(int fd); +int dup2(int fd, int fd2); /* 成功时返回复制的文件描述符,失败时返回 -1 -fildes : 需要复制的文件描述符 -fildes2 : 明确指定的文件描述符的整数值。 +fd : 需要复制的文件描述符 +fd2 : 明确指定的文件描述符的整数值 */ ``` @@ -121,18 +121,18 @@ int main(int argc, char *argv[]) char str1[] = "Hi~ \n"; char str2[] = "It's nice day~ \n"; - cfd1 = dup(1); //复制文件描述符 1 - cfd2 = dup2(cfd1, 7); //再次复制文件描述符,定为数值 7 + cfd1 = dup(1); // 复制文件描述符 1 + cfd2 = dup2(cfd1, 7); // 再次复制文件描述符,指定数值为 7 printf("fd1=%d , fd2=%d \n", cfd1, cfd2); write(cfd1, str1, sizeof(str1)); write(cfd2, str2, sizeof(str2)); close(cfd1); - close(cfd2); //终止复制的文件描述符,但是仍有一个文件描述符 + close(cfd2); // 终止复制的文件描述符,但是仍有一个文件描述符 write(1, str1, sizeof(str1)); close(1); - write(1, str2, sizeof(str2)); //无法完成输出 + write(1, str2, sizeof(str2)); // 无法完成输出 return 0; } @@ -157,29 +157,29 @@ gcc dup.c -o dup - [sep_serv2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_serv2.c) -这个代码可以与 [sep_clnt.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_clnt.c) 配合起来食用,编译过程和上面一样,运行结果为: +这个代码可以与 [sep_clnt.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch16/sep_clnt.c) 配合起来使用,编译过程和上面一样,运行结果为: ![](https://i.loli.net/2019/01/30/5c513d54a27e0.png) ### 16.3 习题 -> 以下答案仅代表本人个人观点,可能不是正确答案。 +> 以下答案仅代表本人个人观点,可能不是标准答案。 1. **下列关于 FILE 结构体指针和文件描述符的说法错误的是**? - 答:以下加粗内容代表说法正确。 + 答:第 1、2、5 项说法错误。 - 1. 与 FILE 结构体指针相同,文件描述符也分输入描述符和输出描述符 - 2. 复制文件描述符时将生成相同值的描述符,可以通过这 2 个描述符进行 I/O - 3. **可以利用创建套接字时返回的文件描述符进行 I/O ,也可以不通过文件描述符,直接通过 FILE 结构体指针完成** - 4. **可以从文件描述符生成 FILE 结构体指针,而且可以利用这种 FILE 结构体指针进行套接字 I/O** - 5. 若文件描述符为读模式,则基于该描述符生成的 FILE 结构体指针同样是读模式;若文件描述符为写模式,则基于该描述符生成的 FILE 结构体指针同样是写模式 + - 第 1 项错误:文件描述符不像 FILE 结构体指针那样分输入和输出模式,同一个文件描述符可以同时进行输入和输出操作。 + - 第 2 项错误:复制文件描述符时生成的是不同值的描述符,但它们指向同一个文件或套接字。 + - 第 3 项正确:可以利用创建套接字时返回的文件描述符进行 I/O,也可以通过 fdopen 函数将文件描述符转换为 FILE 结构体指针后进行 I/O。 + - 第 4 项正确:可以从文件描述符生成 FILE 结构体指针(通过 fdopen 函数),并且可以利用这种 FILE 结构体指针进行套接字 I/O。 + - 第 5 项错误:文件描述符本身没有读模式或写模式的区分,读写模式是在调用 fdopen 函数生成 FILE 结构体指针时指定的。 2. **EOF 的发送相关描述中错误的是**? - 答:以下加粗内容代表说法正确。 + 答:第 1、3 项说法错误。 - 1. 终止文件描述符时发送 EOF - 2. **即使未完成终止文件描述符,关闭输出流时也会发送 EOF** - 3. 如果复制文件描述符,则包括复制的文件描述符在内,所有文件描述符都终止时才会发送 EOF - 4. **即使复制文件描述符,也可以通过调用 shutdown 函数进入半关闭状态并发送 EOF** + - 第 1 项错误:仅终止文件描述符(调用 close)并不一定发送 EOF,只有在所有指向该套接字的文件描述符都被关闭时才会发送 EOF。 + - 第 2 项正确:即使未终止文件描述符,调用 shutdown 函数关闭输出流时也会发送 EOF,这正是半关闭的实现方式。 + - 第 3 项错误:即使复制了文件描述符,也可以通过 shutdown 函数单独关闭输出流并发送 EOF,而不需要关闭所有文件描述符。 + - 第 4 项正确:即使复制了文件描述符,shutdown 函数仍然可以对套接字本身进行半关闭操作,不受文件描述符复制的影响。 diff --git a/ch17/README.md b/ch17/README.md index 4fef88d..b21f8d6 100644 --- a/ch17/README.md +++ b/ch17/README.md @@ -21,9 +21,9 @@ select 性能上最大的弱点是:每次传递监视对象信息,准确的 这样就无需每次调用 select 函数时都向操作系统传递监视对象信息,但是前提操作系统支持这种处理方式。Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。 -#### 17.1.2 select 也有有点 +#### 17.1.2 select 也有优点 -select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,满足以下两个条件使可以使用 select 函数: +select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,满足以下两个条件时可以使用 select 函数: - 服务器接入者少 - 程序应该具有兼容性 @@ -83,7 +83,7 @@ size:epoll 实例的大小 调用 epoll_create 函数时创建的文件描述符保存空间称为「epoll 例程」,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考。 -> Linux 2.6.8 之后的内核将完全忽略传入 epoll_create 函数的 size 函数,因此内核会根据情况调整 epoll 例程大小。但是本书程序并没有忽略 size +> Linux 2.6.8 之后的内核将完全忽略传入 epoll_create 函数的 size 参数,因此内核会根据情况调整 epoll 例程大小。但是本书程序并没有忽略 size epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数 @@ -105,13 +105,13 @@ event:监视对象的事件类型 与其他 epoll 函数相比,该函数看起来有些复杂,但通过调用语句就很容易理解,假设按照如下形式调用 epoll_ctl 函数: -```CQL -epoll_ctl(A,EPOLL_CTL_ADD,B,C); +```c +epoll_ctl(A, EPOLL_CTL_ADD, B, C); ``` 第二个参数 EPOLL_CTL_ADD 意味着「添加」,上述语句有如下意义: -> epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件 +> epoll 例程 A 中注册文件描述符 B,主要目的是为了监视参数 C 中的事件 再介绍一个调用语句。 @@ -142,7 +142,7 @@ epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event); ... ``` -上述代码将 epfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。接下来给出 epoll_event 的成员 events 中可以保存的常量及所指的事件类型。 +上述代码将 sockfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。接下来给出 epoll_event 的成员 events 中可以保存的常量及所指的事件类型。 - EPOLLIN:需要读取数据的情况 - EPOLLOUT:输出缓冲为空,可以立即发送数据的情况 @@ -207,7 +207,7 @@ gcc echo_epollserv.c -o serv 总结一下 epoll 的流程: -1. epoll_create 创建一个保存 epoll 文件描述符的空间,可以没有参数 +1. epoll_create 创建一个保存 epoll 文件描述符的空间(size 参数仅作为建议传递) 2. 动态分配内存,给将要监视的 epoll_wait 3. 利用 epoll_ctl 控制 添加 删除,监听事件 4. 利用 epoll_wait 来获取改变的文件描述符,来执行程序 @@ -290,7 +290,7 @@ Linux 套接字相关函数一般通过 -1 通知发生了错误。虽然知道 int errno; ``` -为了访问该变量,需要引入 `error.h` 头文件,因此此头文件有上述变量的 extren 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。 +为了访问该变量,需要引入 `errno.h` 头文件,因此此头文件有上述变量的 extern 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。 > read 函数发现输入缓冲中没有数据可读时返回 -1,同时在 errno 中保存 EAGAIN 常量 @@ -298,7 +298,7 @@ int errno; ```c #include -int fcntl(int fields, int cmd, ...); +int fcntl(int filedes, int cmd, ...); /* 成功时返回 cmd 参数相关值,失败时返回 -1 filedes : 属性更改目标的文件描述符 @@ -306,14 +306,14 @@ cmd : 表示函数调用目的 */ ``` -从上述声明可以看出 fcntl 有可变参数的形式。如果向第二个参数传递 F_GETFL ,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL ,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下 2 条语句。 +从上述声明可以看出 fcntl 有可变参数的形式。如果向第二个参数传递 F_GETFL ,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL ,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下 2 条语句。 -```C -int flag = fcntl(fd,F_GETFL,0); +```c +int flag = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flag | O_NONBLOCK); ``` -通过第一条语句,获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read/write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。fcntl 函数的适用范围很广。 +通过第一条语句获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read/write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。fcntl 函数的适用范围很广。 #### 17.2.4 实现边缘触发回声服务器端 @@ -346,25 +346,25 @@ gcc echo_EPETserv.c -o serv > 可以分离接收数据和处理数据的时间点! -下面是边缘触发的图 +下面是边缘触发的图: ![](https://i.loli.net/2019/02/01/5c5421e3b3f2b.png) 运行流程如下: -- 服务器端分别从 A B C 接收数据 -- 服务器端按照 A B C 的顺序重新组合接收到的数据 +- 服务器端分别从 A、B、C 接收数据 +- 服务器端按照 A、B、C 的顺序重新组合接收到的数据 - 组合的数据将发送给任意主机。 为了完成这个过程,如果可以按照如下流程运行,服务端的实现并不难: -- 客户端按照 A B C 的顺序连接服务器,并且按照次序向服务器发送数据 -- 需要接收数据的客户端应在客户端 A B C 之前连接到服务器端并等待 +- 客户端按照 A、B、C 的顺序连接服务器,并且按照次序向服务器发送数据 +- 需要接收数据的客户端应在客户端 A、B、C 之前连接到服务器端并等待 但是实际情况中可能是下面这样: - 客户端 C 和 B 正在向服务器发送数据,但是 A 并没有连接到服务器 -- 客户端 A B C 乱序发送数据 +- 客户端 A、B、C 乱序发送数据 - 服务端已经接收到数据,但是要接收数据的目标客户端并没有连接到服务器端。 因此,即使输入缓冲收到数据,服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来很大灵活性。 diff --git a/ch18/README.md b/ch18/README.md index de9e145..bab7993 100644 --- a/ch18/README.md +++ b/ch18/README.md @@ -15,7 +15,7 @@ - 每秒少则 10 次,多则千次的「上下文切换」是创建进程的最大开销 -只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息。这就是上下文切换。但是此时进程 A 的数据将被移动到硬盘,所以上下文切换要很长时间,即使通过优化加快速度,也会存在一定的局限。 +只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存(或保存到寄存器),并读入进程 B 相关信息。这就是上下文切换。上下文切换需要保存和恢复进程的上下文信息(寄存器、程序计数器、栈指针等),这个过程会带来一定的开销,即使通过优化加快速度,也会存在一定的局限。 为了保持多进程的优点,同时在一定程度上克服其缺点,人们引入的线程(Thread)的概念。这是为了将进程的各种劣势降至最低程度(不是直接消除)而设立的一种「轻量级进程」。线程比进程具有如下优点: @@ -70,7 +70,7 @@ int pthread_create(pthread_t *restrict thread, void *(*start_routine)(void *), void *restrict arg); /* -成功时返回 0 ,失败时返回 -1 +成功时返回 0 ,失败时返回错误码(正数) thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程 start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针) @@ -138,13 +138,13 @@ gcc thread1.c -o tr1 -lpthread # 线程相关代码编译时需要添加 -lpthre #include int pthread_join(pthread_t thread, void **status); /* -成功时返回 0 ,失败时返回 -1 +成功时返回 0 ,失败时返回错误码(正数) thread : 该参数值 ID 的线程终止后才会从该函数返回 status : 保存线程的 main 函数返回值的指针的变量地址值 */ ``` -作用就是调用该函数的进程(或线程)将进入等待状态,知道第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。下面是该函数的用法代码: +作用就是调用该函数的进程(或线程)将进入等待状态,直到第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。下面是该函数的用法代码: - [thread2.c](https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch18/thread2.c) @@ -237,7 +237,7 @@ struct hostent *gethostbyname(const char *hostname); struct hostent *gethostbyname_r(const char *name, struct hostent *result, char *buffer, - int intbuflen, + size_t buflen, int *h_errnop); ``` @@ -245,7 +245,7 @@ struct hostent *gethostbyname_r(const char *name, > 声明头文件前定义 `_REENTRANT` 宏。 -无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。 +无需特意更改源代码,可以在编译的时候指定编译参数定义宏。 ```shell gcc -D_REENTRANT mythread.c -o mthread -lpthread @@ -384,7 +384,7 @@ gcc thread4.c -D_REENTRANT -o tr4 -lpthread 任何内存空间,只要被同时访问,都有可能发生问题。 -因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 运算完成。这就是同步(Synchronization) +因此,线程访问变量 num 时应该阻止其他线程访问,直到线程 1 运算完成。这就是同步(Synchronization)。 #### 18.3.2 临界区位置 @@ -392,7 +392,7 @@ gcc thread4.c -D_REENTRANT -o tr4 -lpthread > 函数内同时运行多个线程时引发问题的多条语句构成的代码块 -全局变量 num 不能视为临界区,因为他不是引起问题的语句,只是一个内存区域的声明。下面是刚才代码的两个 main 函数 +全局变量 num 不能视为临界区,因为它不是引起问题的语句,只是一个内存区域的声明。下面是刚才代码的两个线程函数 ```c void *thread_inc(void *arg) @@ -438,9 +438,9 @@ void *thread_des(void *arg) #### 18.4.2 互斥量 -互斥锁(英语:英语:Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全域变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域,但是并不一定会应用互斥锁。 +互斥锁(英语:Mutual exclusion,缩写 Mutex)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全域变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。一个程序、进程、线程可以拥有多个临界区域,但是并不一定会应用互斥锁。 -通俗的说就互斥量就是一把优秀的锁,当临界区被占据的时候就上锁,等占用完毕然后再放开。 +通俗地说,互斥量就是一把锁,当临界区被占据的时候就上锁,等占用完毕然后再放开。 下面是互斥量的创建及销毁函数。 @@ -450,7 +450,7 @@ int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr); int pthread_mutex_destroy(pthread_mutex_t *mutex); /* -成功时返回 0,失败时返回其他值 +成功时返回 0,失败时返回错误码 mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址 attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL */ @@ -477,11 +477,11 @@ pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0 ,失败时返回错误码 */ ``` -函数本身含有 lock unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock 。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。接下来整理一下代码的编写方式: +函数本身含有 lock unlock 等词汇,很容易理解其含义。进入临界区前调用的函数就是 pthread_mutex_lock。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态。接下来整理一下代码的编写方式: ```c pthread_mutex_lock(&mutex); @@ -596,38 +596,38 @@ semaphore对象适用于控制一个仅支持有限个用户的共享资源, int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_destroy(sem_t *sem); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0 ,失败时返回错误码 sem : 创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值 -pshared : 传递其他值时,创建可由多个继承共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为0 +pshared : 传递非 0 值时,创建可由多个进程共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为 0 value : 指定创建信号量的初始值 */ ``` -上述的 shared 参数超出了我们的关注范围,故默认向其传递为 0 。下面是信号量中相当于互斥量 lock unlock 的函数。 +上述的 pshared 参数超出我们的关注范围,故默认向其传递 0。下面是信号量中相当于互斥量 lock unlock 的函数。 ```c #include int sem_post(sem_t *sem); int sem_wait(sem_t *sem); /* -成功时返回 0 ,失败时返回其他值 -sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一 +成功时返回 0 ,失败时返回错误码 +sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增 1,传递给 sem_wait 时信号量减 1 */ ``` -调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1) +调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录着「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1,调用 sem_wait 函数时减 1。但信号量的值不能小于 0,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1): ```c -sem_wait(&sem);//信号量变为0... +sem_wait(&sem);//信号量变为 0... // 临界区的开始 //... //临界区的结束 -sem_post(&sem);//信号量变为1... +sem_post(&sem);//信号量变为 1... ``` -上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。接下来的代码是信号量机制的代码。下面代码并非是同时访问的同步,而是关于控制访问顺序的同步,该场景为: +上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。接下来的代码是信号量机制的代码。下面代码并非是同时访问的同步,而是关于控制访问顺序的同步,该场景为: -> 线程 A 从用户输入得到值后存入全局变量 num ,此时线程 B 将取走该值并累加。该过程一共进行 5 次,完成后输出总和并退出程序。 +> 线程 A 从用户输入得到值后存入全局变量 num,此时线程 B 将取走该值并累加。该过程一共进行 5 次,完成后输出总和并退出程序。 下面是代码: @@ -699,32 +699,34 @@ gcc semaphore.c -D_REENTRANT -o sema -lpthread ![](https://i.loli.net/2019/02/03/5c568c2717d1e.png) -从上述代码可以看出,设置了两个信号量 one 的初始值为 0 ,two 的初始值为 1,然后在调用函数的时候,「读」的前提是 two 可以减一,如果不能减一就会阻塞在这里,一直等到「计算」操作完毕后,给 two 加一,然后就可以继续执行下一句输入。对于「计算」函数,也一样。 +从上述代码可以看出,设置了两个信号量:sem_one 的初始值为 0,sem_two 的初始值为 1,然后在调用函数的时候,「读」的前提是 sem_two 可以减 1,如果不能减 1 就会阻塞在这里,一直等到「计算」操作完毕后,给 sem_two 加 1,然后就可以继续执行下一句输入。对于「计算」函数,也一样。 ### 18.5 线程的销毁和多线程并发服务器端的实现 先介绍线程的销毁,然后再介绍多线程服务端 -#### 18.5.1 销毁线程的 3 种方法 +#### 18.5.1 销毁线程的方法 -Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。 +Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以需要利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。 - 调用 pthread_join 函数 - 调用 pthread_detach 函数 -之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。 +之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,可通过如下函数调用引导线程销毁。 ```c #include -int pthread_detach(pthread_t th); +int pthread_detach(pthread_t thread); /* -成功时返回 0 ,失败时返回其他值 +成功时返回 0,失败时返回错误码 thread : 终止的同时需要销毁的线程 ID */ ``` 调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。 +> 补充说明:Linux 线程有两种状态——「可结合」(joinable)和「分离」(detached)。默认创建的线程是可结合状态,必须被 pthread_join 或 pthread_detach 才能释放其资源。如果线程被设置为分离状态,则线程结束时系统会自动回收其资源,无需其他线程调用 pthread_join。 + #### 18.5.2 多线程并发服务器端的实现 下面是多个客户端之间可以交换信息的简单聊天程序。 @@ -769,22 +771,27 @@ gcc chat_clnt.c -D_REENTRANT -o cclnt -lpthread 4. **下面关于临界区的说法错误的是**? - 答:下面加粗的选项为说法正确。(全错) + 答:这四个选项的说法都是错误的。正确理解如下: - 1. 临界区是多个线程同时访问时发生问题的区域 - 2. 线程安全的函数中不存在临界区,即便多个线程同时调用也不会发生问题 - 3. 1 个临界区只能由 1 个代码块,而非多个代码块构成。换言之,线程 A 执行的代码块 A 和线程 B 执行的代码块 B 之间绝对不会构成临界区。 - 4. 临界区由访问全局变量的代码构成。其他变量中不会发生问题。 + 1. (错误)临界区不是指「区域」,而是指访问共享资源的代码片段。即使多个线程同时访问某些区域,如果通过同步机制保护,也不会发生问题。 + 2. (错误)线程安全的函数中同样可能存在临界区,只是通过锁等机制确保了多线程同时调用时的安全性。 + 3. (错误)临界区可以由多个代码块构成。线程 A 执行的代码块 A 和线程 B 执行的代码块 B,如果它们访问同一共享资源,就可能构成临界区。 + 4. (错误)临界区不仅由访问全局变量的代码构成,任何访问共享资源(如静态变量、堆内存、文件等)的代码都可能构成临界区。 5. **下列关于线程同步的说法错误的是**? - 答:下面加粗的选项为说法正确。 + 答:第 1 和第 4 个说法是错误的,第 2 和第 3 个说法是正确的。 - 1. 线程同步就是限制访问临界区 - 2. **线程同步也具有控制线程执行顺序的含义** - 3. **互斥量和信号量是典型的同步技术** - 4. 线程同步是代替进程 IPC 的技术。 + 1. (错误)线程同步不仅是限制访问临界区,还包括控制线程执行顺序。 + 2. (正确)线程同步也具有控制线程执行顺序的含义。 + 3. (正确)互斥量和信号量是典型的同步技术。 + 4. (错误)线程同步是解决多线程并发问题的技术,而 IPC 是解决进程间通信问题的技术,两者并不互相代替。线程间数据交换无需特殊技术是因为线程共享同一进程的内存空间,而非线程同步代替了 IPC。 6. **请说明完全销毁 Linux 线程的 2 种办法** - 答:①调用 pthread_join 函数②调用 pthread_detach 函数。第一个会阻塞调用的线程,而第二个不阻塞。都可以引导线程销毁。 + 答: + + - **调用 pthread_join 函数**:该函数会阻塞调用线程,直到目标线程终止,然后回收其资源。适用于需要获取线程返回值的场景。 + - **调用 pthread_detach 函数**:该函数将线程设置为分离状态,不会阻塞调用线程。分离状态的线程终止时,系统会自动回收其资源。适用于不关心线程返回值的场景。 + + 注意:一个线程要么被 pthread_join,要么被 pthread_detach,不能两者都调用。未被 join 或 detach 的线程终止后其资源不会被释放,会造成资源泄漏。 diff --git a/ch24/README.md b/ch24/README.md index 255c412..c47be51 100644 --- a/ch24/README.md +++ b/ch24/README.md @@ -16,7 +16,7 @@ web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客 ![](https://i.loli.net/2019/02/07/5c5bc6973a4d0.png) -从上图可以看出,服务器端相应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称「无状态的 Stateless 协议」 +从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称「无状态的 Stateless 协议」。 #### 24.1.3 请求消息(Request Message)的结构 @@ -24,17 +24,17 @@ web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客 ![](https://i.loli.net/2019/02/07/5c5bcbb75202f.png) -从图中可以看出,请求消息可以分为请求头、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。为了降低复杂度,我们实现只能响应 GET 请求的 Web 服务器端,下面解释图中的请求行信息。其中「GET/index.html HTTP/1.1」 具有如下含义: +从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。为了降低复杂度,我们实现只能响应 GET 请求的 Web 服务器端,下面解释图中的请求行信息。其中「GET/index.html HTTP/1.1」 具有如下含义: > 请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。 -请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。 +请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。 -请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。最后的消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。但是我们的目标是实现 GET 方式的服务器端,所以可以忽略这部分内容。另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题 +请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。最后的消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。但是我们的目标是实现 GET 方式的服务器端,所以可以忽略这部分内容。另外,消息体和消息头之间以空行隔开,因此不会发生边界问题。 #### 24.1.4 响应消息(Response Message)的结构 -下面是 Web 服务器端向客户端传递的响应信息的结构。从图中可以看出,该响应消息由状态行、头信息、消息体等 3 个部分组成。状态行中有关于请求的状态信息,这是与请求消息相比最为显著地区别。 +下面是 Web 服务器端向客户端传递的响应信息的结构。从图中可以看出,该响应消息由状态行、头信息、消息体等 3 个部分组成。状态行中有关于请求的状态信息,这是与请求消息相比最为显著的区别。 ![](https://i.loli.net/2019/02/07/5c5bf9ad1b5f9.png) @@ -173,7 +173,7 @@ void send_data(FILE *fp, char *ct, char *file_name) fputs(cnt_len, fp); fputs(cnt_type, fp); - //传输请求数据 + //传输响应体数据 while (fgets(buf, BUF_SIZE, send_file) != NULL) { fputs(buf, fp); @@ -239,19 +239,19 @@ gcc webserv_linux.c -D_REENTRANT -o web_serv -lpthread 1. **下列关于 Web 服务器端和 Web 浏览器端的说法错误的是**? - 答:以下加粗选项代表正确。 + 答:**选项 5 是错误的**。 - 1. **Web 浏览器并不是通过自身创建的套接字连接服务端的客户端** + 1. Web 浏览器是通过自身创建的套接字连接服务端的客户端 2. Web 服务器端通过 TCP 套接字提供服务,因为它将保持较长的客户端连接并交换数据 3. 超文本与普通文本的最大区别是其具有可跳转的特性 4. Web 浏览器可视为向浏览器提供请求文件的文件传输服务器端 - 5. 除 Web 浏览器外,其他客户端都无法访问 Web 服务器端。 + 5. **除 Web 浏览器外,其他客户端都无法访问 Web 服务器端。(错误:任何能发起 HTTP 请求的客户端都可以访问 Web 服务器端,如 curl、wget、编程实现的 HTTP 客户端等)** 2. **下列关于 HTTP 协议的描述错误的是**? - 答:以下加粗选项代表正确。 + 答:**选项 1 是错误的**。 - 1. HTTP 协议是无状态的 Stateless 协议,不仅可以通过 TCP 实现,还可以通过 UDP 来实现 - 2. **HTTP 协议是无状态的 Stateless 协议,因为其在 1 次请求和响应过程完成后立即断开连接。因此,如果同一服务器端和客户端需要 3 次请求及响应,则意味着需要经过 3 次套接字的创建过程**。 - 3. **服务端向客户端传递的状态码中含有请求处理结果的信息**。 - 4. **HTTP 协议是基于因特网的协议,因此,为了同时向大量客户端提供服务,HTTP 协议被设计为 Stateless 协议**。 + 1. **HTTP 协议是无状态的 Stateless 协议,不仅可以通过 TCP 实现,还可以通过 UDP 来实现。(错误:标准 HTTP 协议是基于 TCP 的,HTTP/3 基于 QUIC 协议即 UDP 实现,但传统意义上 HTTP 是基于 TCP 的)** + 2. HTTP 协议是无状态的 Stateless 协议,因为其在 1 次请求和响应过程完成后立即断开连接。因此,如果同一服务器端和客户端需要 3 次请求及响应,则意味着需要经过 3 次套接字的创建过程。 + 3. 服务端向客户端传递的状态码中含有请求处理结果的信息。 + 4. HTTP 协议是基于因特网的协议,因此,为了同时向大量客户端提供服务,HTTP 协议被设计为 Stateless 协议。