从语言基础进入系统接口

网络入口:从socket到一次完整传输

先认清三个文件描述符、地址与事件,再沿实际返回量完成传输;用独立模型拆清截止时间,用有catch的程序观察资源释放。

6个完整小节预计6核心小时,含阅读与编码

先完成SYS-00:位与接口第23章:进程与文件第24章:虚拟内存,再开始本页。

完整G0仍须有自己的独立通过记录,阅读本页不会代替验收。

C++20与POSIX IPv4 loopback。两个模型只计算给定数据;地址与传输程序实际调用系统接口。验证记录实际主机、编译器和原始stdout,不固定系统分配端口或recv切分。发送时对端保持存活;不测试提前关闭,未设置SIGPIPE防护。三模式结果不代表Linux、公网、任意调度或严格实时保证。。时间是设计估算,按完整小节推进;用自己的输入、代码、输出与疑问记录学习过程。

这一页要能做到

  • 区分listener、client、accepted-server的拥有关系与正常退出、已捕获展开、进程终止。
  • 按地址接口读sockaddr_in、网络字节序、port 0与socklen_t输入/输出长度。
  • 按connect/SO_ERROR与poll的合同区分等待、连接完成、EOF和暂不可读。
  • 按实际返回量推进,并解释绝对deadline、整数毫秒截断及观察版的时间取样边界。

先预测,再运行;一次只改一个条件,最后离开示例独立完成,并复述原因。下面的源码与下载是同一份文件,图解和完整文字可逐步对照。

开始前:这一页要帮你读懂哪段程序?

第28章:网络与背压用一段真实TCP程序传送abcdef。难点不是六个字母,而是每次调用之后,你是否知道:哪个资源还活着、哪一步已经成功、实际前进了几个字节、还能等多久。本页先逐个建立这些读法,最后再读完整程序,预计6核心小时,包含推演与动手。

先完成基础G0、SYS-00及系统段的进程、文件和虚拟内存内容。引用、类、RAII、数组、字符串、位掩码、异常和多文件编译沿用已学章节。本页有四份可下载源码:两个本机接口观察程序、一个事件模型和一个时间模型。模型给定的返回值不冒充真实内核记录。

四个文件各有自己的main,分别编译。例如下载首个文件后,在它所在的目录运行:

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror address-and-ownership.cpp -o address-and-ownership
./address-and-ownership

也可使用支持C++20的g++;先用--version确认实际工具链。macOS的g++命令可能仍是Apple Clang。这里的socket接口来自POSIX系统头文件,macOS/Linux可用;Windows的Winsock接口与初始化流程不在这四份程序的范围内。这四份文件已在macOS 15.5、Apple Clang 17下完成Debug、Release及ASan/UBSan检查;模型结果、正常传输、容量改为1及受控超时均符合各自预期。Linux尚未实际执行;检查通过也不等于测试了任意网络调度。

N1 · 一次连接为什么有三个fd?

socket是程序与通信设施交互的端点。::socket(AF_INET, SOCK_STREAM, 0)请求一个IPv4、字节流端点;AF_INET选择IPv4地址家族,SOCK_STREAM选择流类型,第三个参数0让系统选择这个组合的默认协议,在这里使用TCP。::表示在全局作用域找这个接口。成功返回非负的文件描述符(file descriptor,fd),失败返回-1并设置errno。0也可以是有效fd;不能用fd <= 0判断失败。

fd是当前进程用于查找资源的整数句柄,不是资源本身,也不是网络端口号。下面用L、C、S标识资源角色;实际fd整数由系统分配,不能预先假定是3、4、5。关闭后的编号以后可能再次分配给别的资源,所以“我还记得数字4”不证明原来的socket还开着。

角色 怎样得到 做什么 谁负责关闭
listener,记为L 第一次socket 监听新连接 拥有L的Fd对象
client,记为C 第二次socket 发起连接、发送字节 拥有C的Fd对象
server,记为S 后面的accept 这条已接受连接在接收侧的端点 拥有S的Fd对象

listener不是用来接收这条连接的abcdefaccept另交给程序一个连接fd,listener继续承担监听职责;客户端和接受端也各有自己的fd,即使它们恰好处在同一个进程。

::close(fd)释放这个描述符。成功返回0,失败返回-1;不能把“调用过close”直接写成“close必然成功”。沿用基础13的独占拥有,一个Fd对象接管一个有效fd,析构函数负责尝试关闭,删除复制操作防止两个对象对同一个编号各关一次。get()只借出编号给系统调用,不转移关闭责任。资源包装器的析构函数不应该向外抛异常。

还要区分两种失败:socket返回-1时没有得到新的有效fd;若包装器构造函数因此抛出,那个尚未构造完成的Fd本身不会再执行析构。只有之前已经成功构造的对象,才进入后面讨论的正常销毁或异常展开过程。这里不重新实现通用资源库,只读当前程序的一个拥有关系。

先在纸上画三个空格L、C、S。每次得到有效fd才填入;每次关闭才划掉。不要在connect成功时凭空再画一个新的客户端fd,也不要在accept成功后划掉L。

N2 · 端口0怎么变成可连接的地址?

IPv4端点需要地址和端口。sockaddr_in是系统头文件提供的IPv4地址结构;sockaddr_in address{}对这个聚合对象做空列表初始化,未显式提供的标量字段得到零值。它不是“任何类写一对大括号都会把对象全部字节清零”的普遍规则。程序随后给需要的字段赋值:

表达式 本次含义
address.sin_family = AF_INET 这是一份IPv4地址
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK) IPv4回环地址,只与本机通信
address.sin_port = htons(0) 让绑定操作选择一个可用端口

嵌套的两个点表示先访问sin_addr成员,再访问里面的s_addr成员。INADDR_LOOPBACK是本机回环地址的常量,常见文字写法为127.0.0.1;它不指向局域网里的另一台机器。这里的端口0是请求系统分配端口,不是要求客户端最终去连接“0号端口”。

网络接口规定这些多字节地址字段使用网络字节序(network byte order),也就是高有效字节在前。htons把16位值从主机表示转换成网络序;htonl处理32位值。名字里的short/long是这组历史接口的叫法,不是让你按当前C++的sizeof(long)猜参数位宽。逆方向分别是ntohsntohl。同一个端口数值258以网络序表示时,两字节按01 02排列;在SYS的低有效字节在前编码里则为02 01。转换是为了满足接口的存储约定,端口的含义没有变成另一个数。

bind成功后端口已由系统选定,但传进去的address不会因此自动更新。getsockname才负责把该socket当前的本地地址写回来。它的核心调用形状是getsockname(fd, 地址缓冲区指针, 长度变量的指针)。准备length = sizeof address,是声明缓冲区容量;调用成功后,同一个length变量变成系统报告的地址长度。socklen_t是接口规定的地址长度类型,与传输字节数的ssize_t用途不同。

时刻 address.sin_port的含义 length的含义
调用bind前 网络序的0:请分配端口 尚未用于返回地址
bind成功后 用户对象仍是原来的0 系统已知道实际端口
getsockname调用前 为系统准备的地址存储空间 可写容量sizeof address
getsockname成功后 系统写回的实际端口,仍为网络序 实际地址长度
打印时 ntohs恢复端口数值P 不把结构大小写死成某个跨平台数字

本例提供完整的sockaddr_in空间。一般接口还必须考虑缓冲区太小造成截断;不能仅因返回长度是一个正数就忽略容量。源码中的sizeof address读法沿用基础08,取这个结构对象所需的字节大小,不取网络报文长度。

还有一段必须正面读懂的语法:reinterpret_cast<sockaddr*>(&address)。尖括号内是目标指针类型sockaddr*,括号内是原来的IPv4结构地址。这次转换是为了满足POSIX使用通用sockaddr参数接收具体地址结构的约定;具体家族和长度告诉接口如何解释它。代码没有把这个指针当成任意新对象去读写,也没有靠转换新建地址数据。不要把它推广成“任意T指针转成任意U指针后都能合法解引用”。对仅读取地址的接口也可以传const sockaddr*,写回地址的接口则需要可写空间。

观察 · 推演

端口0是请求,输出参数给出实际地址

sin_familyAF_INETsin_addrhtonl(loopback)sin_porthtons(0)01 / 04 · MEMORYsin_familyAF_INETsin_addrhtonl(loopback)sin_porthtons(0)01 / 04 · MEMORY
1 · 提交地址

sin_family选择IPv4,loopback与端口经过相应host→network转换;端口0请求系统选可用端口。

1 / 4
阅读完整推演文字
  1. 1 · 提交地址

    sin_family:AF_INET;sin_addr:htonl(loopback);sin_port:htons(0)

    sin_family选择IPv4,loopback与端口经过相应host→network转换;端口0请求系统选可用端口。

  2. 2 · 绑定完成

    输入对象:原端口请求0;已绑定端点:实际端口未知

    bind成功后端点有了系统选择的端口;不能从旧输入的0猜出它。

  3. 3 · 读取输出

    输入length:sizeof address;输出address:含实际网络序端口;输出length:实际长度

    getsockname接收address缓冲及length地址;length先是可写容量,成功后变为实际地址长度。

  4. 4 · 显示观察

    网络序字段:sin_port;主机数值:ntohs(sin_port)

    ntohs把实际端口转换为供人阅读的主机数值;每次运行可以不同,断言其在1到65535内。

现在做一次纸上预测:请求端口0,系统在本次运行选出P,getsockname更新地址。之后client应使用更新后的地址与长度,而不是重新创建一个仍含端口0的对象。P由实际运行记录决定,不作为固定答案。

N3 · 从绑定到接受,哪一步改变了什么?

先不考虑“是否要等”,按正常成功路径追踪职责。下一节才给这些操作加入非阻塞状态。

调用 成功后的变化 成功返回 失败返回
bind(L, address, length) 把L绑定到本地地址和端口 0 -1,检查errno
listen(L, 1) L开始监听;1是待接受连接队列的backlog请求 0 -1,检查errno
connect(C, address, length) C向目标发起连接;正常成功分支连接已建立 0 -1,按下一节区分原因
accept(L, nullptr, nullptr) 取出一条可接受连接,产生新的S 非负的新fd -1,检查errno

listen的backlog不是“程序一生最多接受一个连接”,实际排队行为还受实现限制影响。accept的两个nullptr表示本例不要求把对端地址与长度写回来;不是一个故意制造的空指针错误,也不会影响它返回新的连接fd。一个fd发生错误时,不能把别的fd角色也一起改成“连接失败”。

完整例子里的过程是:创建L→bind→listen→取得实际地址→创建C→connect→确认连接→accept得到S。网络端点的连接过程与应用调用accept并非同一件事;客户端建立连接不要求接收侧已经执行了那一行accept。接收侧先有监听socket,系统可以把连接排队,随后应用再接受。

下面先运行一个更短的地址与清理观察程序:它只建立、绑定一个本机端点并取回端口,不提前进行完整连接与传输。它把析构中的关闭结果打印出来,另提供在try内部主动抛出异常的模式,观察已捕获异常展开时的清理顺序。

读命令行入口时,int main(int argc, char* argv[])仍是main的一种标准形式。argc是参数个数,argv按下标给出以零字符终止的字符串指针;程序名占argv[0]。运行./address-and-ownership unwind时,argc为2,argv[1]unwind。必须先检查argc足够,再访问该下标;std::string{argv[1]}把这个有效的字符串复制到拥有自己的字符存储的string。不要对缺失参数的地址构造string。

观察程序里的Observation只保存关闭尝试次数与是否全部返回0。它建立在try外面,里面的Fd借用它;因此Fd析构写回观察结果时,Observation仍然存活。catch (const std::exception& error)通过标准异常基类接住这里的runtime_error,what()沿用基础16的说明文字读法。std::cerr是标准错误输出流,写法与cout相同,这里用于报告意外失败;默认不为这些流开启抛异常的设置。

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <iostream>
#include <stdexcept>
#include <string>

struct Observation {
    int closed{0};
    bool close_ok{true};
};

class Fd {
    int value_;
    Observation& observation_;
public:
    Fd(int value, Observation& observation)
        : value_{value}, observation_{observation} {
        if (value_ < 0) throw std::runtime_error("socket");
        std::cout << "open=listener\n";
    }
    Fd(const Fd&) = delete;
    Fd& operator=(const Fd&) = delete;
    ~Fd() {
        const int result{::close(value_)};
        ++observation_.closed;
        observation_.close_ok = observation_.close_ok && result == 0;
        std::cout << "close=listener result=" << result << '\n';
    }
    int get() const { return value_; }
};

int main(int argc, char* argv[]) {
    const std::string mode{argc == 2 ? argv[1] : "normal"};
    if (argc > 2 || (mode != "normal" && mode != "unwind")) return 2;
    Observation observation;
    bool caught{false};
    try {
        Fd listener{::socket(AF_INET, SOCK_STREAM, 0), observation};
        sockaddr_in address{};
        address.sin_family = AF_INET;
        address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        address.sin_port = htons(0);
        if (::bind(listener.get(), reinterpret_cast<sockaddr*>(&address),
                   sizeof address) != 0) throw std::runtime_error("bind");
        socklen_t length{sizeof address};
        if (::getsockname(listener.get(), reinterpret_cast<sockaddr*>(&address),
                          &length) != 0) throw std::runtime_error("getsockname");
        const auto port{ntohs(address.sin_port)};
        const bool loopback{ntohl(address.sin_addr.s_addr) == INADDR_LOOPBACK};
        const bool length_ok{length == sizeof address};
        std::cout << "port=" << port << " loopback=" << loopback
                  << " length-ok=" << length_ok << '\n';
        if (port == 0 || !loopback || !length_ok) throw std::runtime_error("address");
        if (mode == "unwind") throw std::runtime_error("lesson-unwind");
        std::cout << "body=normal\n";
    } catch (const std::exception& error) {
        if (mode != "unwind" || std::string{error.what()} != "lesson-unwind") {
            std::cerr << "unexpected=" << error.what() << '\n';
            return 1;
        }
        caught = true;
        std::cout << "caught=lesson-unwind\n";
    }
    std::cout << "closed=" << observation.closed << " close-ok="
              << observation.close_ok << " caught=" << caught << '\n';
    return observation.closed == 1 && observation.close_ok &&
           caught == (mode == "unwind") ? 0 : 1;
}

正常模式中,先看到open=listener和本次分配的端口,再看到body=normal、关闭结果以及closed=1。只增加unwind这个参数,预测哪一行会抛出、哪个已经构造完成的对象先析构、catch何时打印。实际端口可能变化;unwind模式应先打印close=listener result=0,再打印caught=lesson-unwind,最后caught=1。日志用角色名标识拥有者,不打印fd编号。

./address-and-ownership unwind
echo $?

这里预期的受控异常被处理后,程序核对观察结果并返回0;不是“只要程序抛过异常,进程退出码就必为1”。意外异常或关闭结果不符才失败。

这份受控观察与旧24-b的限制要分开:旧程序的main没有包住全程的catch。没有匹配处理器而进入终止过程时,不能保证你期望的全部栈展开;操作系统在进程退出后回收fd,也不能证明C++的Fd析构函数执行过。独立观察程序把异常放在有匹配catch的路径上,并记录析构和close返回,才是在观察这个具体清理过程。这个区别由C++异常终止规则规定。

完整连接程序在正常结束时,S对象比C后构造,C又比L后构造,因此逆序销毁S→C→L。对象之间的字符串或计数变量也会销毁,但不要把这些无fd对象混进“三个关闭责任”的表里。

N4 · 暂时做不了,为什么不一定失败?

阻塞(blocking)调用可以等待条件满足后再返回;非阻塞(nonblocking)调用在暂时无法完成时,把当前状态交还给程序。程序于是可以等待事件、做别的工作或检查截止时间。非阻塞不是“每次必定成功”,也不代表一调用就传完全部字节。

fcntl(fd, F_GETFL, 0)读取已有文件状态标志,失败为-1。取得有效flags之后,fcntl(fd, F_SETFL, flags | O_NONBLOCK)在保留其他位的基础上请求非阻塞模式,成功为0、失败为-1。这里的|正是SYS教过的合并位掩码。不要把flags | O_NONBLOCK写成逻辑或,也不要省略读取原flags而默认它们全为0。F_SETFL能修改哪些标志由系统接口规定,并不是任意位都能随便改。fcntl状态标志合同

现在看失败返回后的具体原因。只有该调用按合同报告错误时,errno才有本次判断意义;成功后留下的旧errno不能单独判失败。

调用与结果 当前含义 本例下一步
非阻塞connect返回0 已连接 可以进入连接后的操作
非阻塞connect返回-1且errno为EINPROGRESS 连接尚在进行 等待写事件,再查询完成状态
非阻塞send/recv返回-1且为EAGAIN或EWOULDBLOCK 当前无法推进 等待相应事件,不增加偏移
poll或本例send/recv返回-1且为EINTR 本次被信号中断 在本例的相应循环里重试,保留原截止时间
其他错误 不在本例可重试分支 进入错误处理

有些系统把EAGAINEWOULDBLOCK设为相同值,也有接口需要同时考虑两者,所以代码用或条件列出。不能把这张表扩成“任何系统调用遇到EINTR都可以原样重试”;这里的判断只覆盖表中调用和当前状态。没有实际返回正数时,不能先假定传过了一部分再增加计数。

写就绪还不是connect成功的最终证据。等待结束后调用getsockopt(C, SOL_SOCKET, SO_ERROR, &error, &length)SOL_SOCKET选择socket层选项,SO_ERROR取得待处理socket错误;调用前把length设为sizeof error,提供一个int大小的输出空间。这一行有两种结果:函数本身返回0/-1,说明查询是否成功;输出参数error为0或错误码,说明被查询的连接完成状态。查询成功但error非0,仍不能开始发送正常数据。connect完成状态合同

按给定输入走两次:一次查询返回0且error=0,可以进入已连接状态;另一次查询返回0但error是连接被拒绝的错误码,必须进入连接失败分支。“查询成功”和“连接成功”不是同一个布尔值。这里没有要求你背下每个系统的错误码数字。

旧24-b在accept前等待listener可读,然后调用一次accept。它没有为accept另外实现EINTR或暂不可用的重试分支,因此只能按这份有限教学程序的实际范围理解,不能把它当成完整的长期服务端实现。

N5 · poll返回1,究竟是什么东西有1个?

poll等待一组描述符的状态变化。先读一个pollfd对象:它按顺序含fdeventsrevents三个成员。events是我关心的事件位,revents是系统实际写回的事件位;这两个字段使用short,一种有符号整数类型,至少能表示-32767到32767。位操作的整数提升仍遵循SYS的规则,不因为字段名叫short就自行按8位算。

pollfd event{fd, events, 0}按聚合成员顺序初始化这三个字段。模型中的static_cast<short>(POLLIN | POLLHUP)把本例已知可由short表示的事件组合显式转回字段类型,再写入revents;这个转换不替任意整数检查范围。poll(&event, 1, timeout_ms)传入对象地址、待检查的项数1、最多等待的毫秒数。它返回正数时,数的是有非零revents的项数,不是字节数,也不是单个fd上的事件位数。一个fd同时有POLLIN和POLLHUP,返回项数仍可能只有1。返回0表示这次等待超时,-1表示错误并按errno处理。timeout为0是立即检查,不等一毫秒。poll接口

返回事件位 读法 不能推出什么
POLLIN 可以尝试读取当前可用内容或读取结束状态 不保证有完整应用消息
POLLOUT 可以尝试写入 不保证一次发送全部剩余字节,也不代替SO_ERROR
POLLHUP 这一通道报告挂断 不保证缓冲里没有未读数据
POLLERR 报告错误条件 不当成普通成功事件继续
POLLNVAL fd不是有效的打开描述符 不继续用这个fd收发

这几个名字是系统头文件里的位常量,图不硬编码它们的数字。用(event.revents & POLLIN) != 0检查可读位,用event.revents & (events | POLLHUP)检查是否命中了所请求的位或挂断位。后一表达式得到整数,在if条件中非零转为真。位筛选与布尔解释分两步看,不能把两个&改成&&

假设给定返回掩码为POLLIN | POLLHUP,还有ab留在接收缓冲中。先读出这两个字节,下一次再读到0,才得到本次流的EOF:对端这个发送方向结束,并且待读数据已排空。如果一见HUP就立刻丢弃缓冲,可能漏掉最后的字节。本页只对正长度recv讨论这个EOF合同,不能用请求0字节的调用证明对端结束。

观察 · 推演

就绪项、收到的字节与完整内容是三件事

eventsPOLLINreventsPOLLIN|POLLHUPpoll返回1个就绪项01 / 04 · MEMORYeventsPOLLINreventsPOLLIN|POLLHUPpoll返回1个就绪项01 / 04 · MEMORY
1 · 给定事件

这张图是给定模型:poll返回1,revents为POLLIN|POLLHUP;1数的是就绪项。

1 / 4
阅读完整推演文字
  1. 1 · 给定事件

    events:POLLIN;revents:POLLIN|POLLHUP;poll返回:1个就绪项

    这张图是给定模型:poll返回1,revents为POLLIN|POLLHUP;1数的是就绪项。

  2. 2 · 尚有数据

    返回量:2;偏移:0→2;内容:ab

    即使报告HUP,仍可能有数据;模型先返回2,累计ab,偏移2。

  3. 3 · 按实际量继续

    返回1:abc / 3;返回3:abcdef / 6

    再给1和3,偏移从2到3再到6。此3字节模型与容量2的真实程序分开。

  4. 4 · 最后才是EOF

    返回量:0;偏移:6不变;数据:abcdef

    模型最终返回0;没有新字节,偏移保持6。事件本身没有产生或保留应用消息边界。

下面的模型手动给出事件与返回长度2、1、3;假设接收空间至少3字节,依次得到abcdef,偏移是2、3、6。这个序列不是系统实际拆包的记录,也不是下方“最多收2字节”的真实程序所能产生的单次3字节结果。模型的用途是把返回项数、事件位与字节进展分开。

模型用output.append(input, offset, count)追加字符串的一段:从input的offset位置复制count个字符到output末尾。例如offset=2、count=1时,从abcdef取出c。调用前检查count没有超出剩余输入;每次复制之后才推进offset。此处使用的是string来源加起点、长度的重载;后面的真实接收会使用另一种“字符指针+长度”重载。

#include <poll.h>
#include <array>
#include <iostream>
#include <string>

int main() {
    // Supplied observations, not a poll/recv system-call trace.
    pollfd event{7, POLLIN, static_cast<short>(POLLIN | POLLHUP)};
    const int poll_result{1};
    std::cout << "model=given-sequence\n";
    std::cout << "poll-count=" << poll_result
              << " readable=" << ((event.revents & POLLIN) != 0)
              << " hup=" << ((event.revents & POLLHUP) != 0) << '\n';
    const std::string input{"abcdef"};
    const std::array<int, 3> returns{2, 1, 3};
    std::size_t offset{0};
    std::string output;
    for (const int returned : returns) {
        if (returned <= 0) return 1;
        const auto count{static_cast<std::size_t>(returned)};
        if (count > input.size() - offset) return 1;
        output.append(input, offset, count);
        offset += count;
        std::cout << "returned=" << returned << " offset=" << offset
                  << " data=" << output << '\n';
    }
    const int eof_return{0};
    std::cout << "returned=" << eof_return << " eof=1 offset=" << offset << '\n';
    return output == input && offset == 6 ? 0 : 1;
}

先预测各次累积偏移,再运行。只修改一个给定进展值时,重新核算总长度;若长度超过剩余目标,程序不能悄悄把它算成成功。真实程序中使用的是send/recv实际返回的长度,下一节再把这条规则接到socket上。

N6 · 怎样在同一个截止时间内传完六个字节?

先把“几点”与“多久”分开。std::chrono::steady_clock是适合测量间隔的单调时钟,不用它当日历时间。using Clock = std::chrono::steady_clock给类型起短名;Clock::now()取得一个时间点(time_point)。给时间点加一段时长(duration)得到另一个时间点;两个同类时间点相减得到时长,不能把时间点本身误读成毫秒整数。

std::chrono::seconds(2)创建2秒时长;std::chrono::milliseconds(500)是500毫秒,microseconds(500)是500微秒,也就是0.5毫秒。duration_cast<std::chrono::milliseconds>(deadline - now)把差值转成毫秒时长,后面的.count()才取出以这个单位计数的数值。本例使用整数表示,正的0.5毫秒转成整数毫秒得到0;转换不会替我们向上取整。chrono时长转换

现在把起点写成0,固定终点2000毫秒。在已用1500毫秒时,剩余量是500毫秒。重试仍用这个终点;若错误地在每次重试时重新计算“当前时间+2000毫秒”,终点会变成3500毫秒,以后又能继续往后推。这不再是最初说好的总预算。

代码里的using std::chrono::milliseconds;是using声明,让当前作用域可以直接写milliseconds这个已有名字;与using Clock = ...创建类型别名的形式不同。Clock::time_point start{}创建相对于该时钟纪元的零时长时间点,只作为模型坐标0,不表示今天凌晨或真实开机时刻。源码也用时间点减时长得到更早的时间点,并把deadline加1毫秒设成一个假定的晚到观察点。

观察 · 推演

固定终点与整数毫秒的边界

start0msdeadline2000ms01 / 04 · MEMORYstart0msdeadline2000ms01 / 04 · MEMORY
1 · 确定终点

以固定起点为0ms,终点2000ms;图不表示一次实际计时。

1 / 4
阅读完整推演文字
  1. 1 · 确定终点

    start:0ms;deadline:2000ms

    以固定起点为0ms,终点2000ms;图不表示一次实际计时。

  2. 2 · 1500ms重试

    重试点:1500ms;保留终点:剩500ms;重设终点:剩2000ms

    原终点还剩500ms;错误地从重试点另加2000ms,会把终点推到3500ms。

  3. 3 · 半毫秒仍为正

    原剩余:500μs;整数毫秒:0ms

    剩500μs转换为整数milliseconds后count为0;这是单位转换的截断,不是时间点已经越过。

  4. 4 · 等待后还要观察

    deadline:2000ms;给定就绪时点:2001ms;差:晚1ms

    给定就绪发生在2001ms,比终点晚1ms。实际观察版增加等待后取样检查,仍不宣称实时调度保证。

#include <chrono>
#include <iostream>

int main() {
    using Clock = std::chrono::steady_clock;
    using std::chrono::duration_cast;
    using std::chrono::milliseconds;
    using std::chrono::microseconds;
    const Clock::time_point start{};
    const auto deadline{start + milliseconds{2000}};
    const auto retry_time{start + milliseconds{1500}};
    const auto kept_left{duration_cast<milliseconds>(deadline - retry_time).count()};
    const auto reset_deadline{retry_time + milliseconds{2000}};
    const auto reset_left{duration_cast<milliseconds>(reset_deadline - retry_time).count()};
    const auto just_before{deadline - microseconds{500}};
    const auto positive_us{duration_cast<microseconds>(deadline - just_before).count()};
    const auto truncated_ms{duration_cast<milliseconds>(deadline - just_before).count()};
    const auto simulated_ready_time{deadline + milliseconds{1}};
    const auto overrun{duration_cast<milliseconds>(simulated_ready_time - deadline).count()};
    std::cout << "model=fixed-time-points\n";
    std::cout << "kept-left-ms=" << kept_left << " reset-left-ms=" << reset_left << '\n';
    std::cout << "positive-us=" << positive_us << " truncated-ms=" << truncated_ms << '\n';
    std::cout << "simulated-ready-overrun-ms=" << overrun << '\n';
    return kept_left == 500 && reset_left == 2000 && positive_us == 500 &&
           truncated_ms == 0 && overrun == 1 ? 0 : 1;
}

这是固定时间点的运算模型,不需要真的睡2秒,也不拿运行耗时当答案。先预测固定终点剩500毫秒、错误重设后又剩2000毫秒,以及0.5毫秒被取整成0。最后一行的越过量1毫秒是人为选定时间点相减的结果,没有测量任何真实poll耗时。只改“已经用去多久”这一条件,固定终点不随之改动。

把返回量接到发送和接收

send(fd, buffer, count, 0)从有效的buffer范围请求发送count字节;recv(fd, buffer, count, 0)把收到的字节写入调用者提供的空间。末尾0表示本例不请求额外标志。两者返回ssize_t:正数是实际进展,-1走已讲的errno分支。本例始终请求正长度;recv返回0表示流接收结束。send没有进展时,代码采用失败策略,不能无限原地循环。

发送offset最初为0。input.data() + sent指向尚未发送的第一个字符,input.size() - sent给出剩余长度。只有返回正数n,才把sent增加n。发送成功表示这次调用接受了这些字节,不证明远端应用已经处理它们;最后还要在接收端比较实际内容。

接收侧每次提供最多2字节的空间。若n为1,只能使用buffer里的第一个有效字节;后面尚未写入的字节不能一起读。output.append(buffer, static_cast<std::size_t>(n))这个string重载从指针位置复制恰好n个字符,追加到output;它不要求buffer以零字符结尾。与接收数量无关地把buffer当C字符串输出,可能把无效区域也读进去。

shutdown(C, SHUT_WR)关闭客户端的发送方向,成功返回0。已经正常提交的字节仍可被接收侧读出,然后接收侧获得EOF;它不等于close(C),C的描述符仍由Fd拥有并在最后关闭,另一个方向的能力也不能直接视为已关闭。

观察 · 推演

三个独立拥有者,三次独立close

listener监听端点client尚未建立server尚未接受01 / 04 · MEMORYlistener监听端点client尚未建立server尚未接受01 / 04 · MEMORY
1 · 监听者建立

listener持有监听端点;接受连接以后它仍存在。

1 / 4
阅读完整推演文字
  1. 1 · 监听者建立

    listener:监听端点;client:尚未建立;server:尚未接受

    listener持有监听端点;接受连接以后它仍存在。

  2. 2 · 客户端与已接受端

    listener:保留监听;client:连接一端;server:连接另一端

    client和accept返回的server是连接两端;server不会取代listener。

  3. 3 · 关闭发送方向

    listener:仍拥有;client:禁止继续发送;server:读取至EOF

    shutdown(SHUT_WR)结束client的发送方向,不销毁Fd;已排队字节之后可观察EOF。

  4. 4 · 已知作用域退出

    第1次close:server;第2次close:client;第3次close:listener

    本例正常退出或进入外层catch的展开路径,按声明的相反顺序close server、client、listener。未捕获终止不能据此推断。

最后再读完整观察程序

下面的观察版把各次正返回长度、最终数据和close事件打印出来,供你与预测表对照。它带有匹配的catch,可以观察受控异常路径中的销毁。端口和每次recv切分由实际运行决定;固定答案只有数据顺序与总量等合同,不能规定每次一定收abcdef

整读之前,先把这个观察版与旧24-b的三处区别记下来。第一,它对accept的EINTR/EAGAIN/EWOULDBLOCK回到等待循环。第二,它把所有Fd放在有匹配catch的try内。第三,它的wait_ready先用时间点比较是否已到deadline;若剩余正数不足1毫秒,就用1毫秒请求poll,并在poll之后重新取样检查时钟;poll返回0时重新检查同一个终点。这个较晚的取样仍不保证调度器在函数实际返回前不会暂停程序。

普通模式的连接、发送与接收共用最初2000毫秒终点。timeout模式是另一个明确分开的实验:连接成立之后保持两端打开、不发送数据,为这一次无数据等待建立30毫秒终点。它不是在正常传输的重试循环里不断延长预算。timeout_armed先标记已经进入这次实验,让连接建立阶段意外发生的deadline异常不能冒充预期超时。

#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

struct Observation {
    int closed{0};
    bool close_ok{true};
};

class Fd {
    int value_;
    const char* name_;
    Observation& observation_;
public:
    Fd(int value, const char* name, Observation& observation)
        : value_{value}, name_{name}, observation_{observation} {
        if (value_ < 0) throw std::runtime_error(name_);
        std::cout << "open=" << name_ << '\n';
    }
    Fd(const Fd&) = delete;
    Fd& operator=(const Fd&) = delete;
    ~Fd() {
        const int result{::close(value_)};
        ++observation_.closed;
        observation_.close_ok = observation_.close_ok && result == 0;
        std::cout << "close=" << name_ << " result=" << result << '\n';
    }
    int get() const { return value_; }
};

using Clock = std::chrono::steady_clock;

void nonblocking(int fd) {
    const int flags{::fcntl(fd, F_GETFL, 0)};
    if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0)
        throw std::runtime_error("fcntl");
}

void wait_ready(int fd, short events, Clock::time_point deadline) {
    for (;;) {
        const auto now{Clock::now()};
        if (now >= deadline) throw std::runtime_error("deadline");
        auto left{std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count()};
        if (left == 0) left = 1; // Wait at least 1 ms when a positive fraction remains.
        // This demonstration only supplies deadlines at most 2000 ms ahead.
        pollfd event{fd, events, 0};
        const int result{::poll(&event, 1, static_cast<int>(left))};
        if (result < 0 && errno == EINTR) continue;
        if (result < 0) throw std::runtime_error("poll");
        if (Clock::now() >= deadline) throw std::runtime_error("deadline");
        if (result == 0) continue; // An early, rounded timeout does not reset the deadline.
        if ((event.revents & (POLLERR | POLLNVAL)) != 0)
            throw std::runtime_error("poll-event");
        if ((event.revents & (events | POLLHUP)) != 0) return;
    }
}

int main(int argc, char* argv[]) {
    const std::string mode{argc == 2 ? argv[1] : "normal"};
    if (argc > 2 || (mode != "normal" && mode != "one-byte" && mode != "timeout")) return 2;
    Observation observation;
    bool caught_timeout{false};
    bool timeout_armed{false};
    try {
        const auto deadline{Clock::now() + std::chrono::milliseconds{2000}};
        Fd listener{::socket(AF_INET, SOCK_STREAM, 0), "listener", observation};
        sockaddr_in address{};
        address.sin_family = AF_INET;
        address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        address.sin_port = htons(0);
        if (::bind(listener.get(), reinterpret_cast<sockaddr*>(&address), sizeof address) != 0 ||
            ::listen(listener.get(), 1) != 0) throw std::runtime_error("bind/listen");
        socklen_t length{sizeof address};
        if (::getsockname(listener.get(), reinterpret_cast<sockaddr*>(&address), &length) != 0)
            throw std::runtime_error("getsockname");
        std::cout << "port=" << ntohs(address.sin_port) << '\n';
        nonblocking(listener.get());
        Fd client{::socket(AF_INET, SOCK_STREAM, 0), "client", observation};
        nonblocking(client.get());
        if (::connect(client.get(), reinterpret_cast<sockaddr*>(&address), length) != 0 &&
            errno != EINPROGRESS) throw std::runtime_error("connect");
        wait_ready(client.get(), POLLOUT, deadline);
        int error{0};
        length = sizeof error;
        if (::getsockopt(client.get(), SOL_SOCKET, SO_ERROR, &error, &length) != 0 || error != 0)
            throw std::runtime_error("SO_ERROR");
        std::cout << "connect-error=" << error << '\n';
        int accepted{-1};
        while (accepted < 0) {
            wait_ready(listener.get(), POLLIN, deadline);
            accepted = ::accept(listener.get(), nullptr, nullptr);
            if (accepted < 0 && errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK)
                throw std::runtime_error("accept");
        }
        Fd server{accepted, "server", observation};
        nonblocking(server.get());
        if (mode == "timeout") {
            timeout_armed = true;
            std::cout << "waiting=no-data\n";
            wait_ready(server.get(), POLLIN, Clock::now() + std::chrono::milliseconds{30});
            throw std::runtime_error("unexpected-readiness");
        }
        const std::string input{"abcdef"};
        std::size_t sent{0};
        // The peer remains alive. This file does not test premature close or suppress SIGPIPE.
        while (sent < input.size()) {
            wait_ready(client.get(), POLLOUT, deadline);
            const auto n{::send(client.get(), input.data() + sent, input.size() - sent, 0)};
            if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
            if (n <= 0) throw std::runtime_error("send");
            sent += static_cast<std::size_t>(n);
            std::cout << "send=" << n << " total=" << sent << '\n';
        }
        if (::shutdown(client.get(), SHUT_WR) != 0) throw std::runtime_error("shutdown");
        std::cout << "shutdown=write\n";
        char buffer[2];
        const std::size_t capacity{mode == "one-byte" ? 1U : 2U};
        std::string output;
        for (;;) {
            wait_ready(server.get(), POLLIN, deadline);
            const auto n{::recv(server.get(), buffer, capacity, 0)};
            if (n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)) continue;
            if (n < 0) throw std::runtime_error("recv");
            if (n == 0) break;
            output.append(buffer, static_cast<std::size_t>(n));
            std::cout << "recv=" << n << " total=" << output.size() << '\n';
        }
        if (output != input) throw std::runtime_error("data");
        std::cout << "data=" << output << " eof=1 capacity=" << capacity << '\n';
    } catch (const std::exception& error) {
        if (mode != "timeout" || !timeout_armed || std::string{error.what()} != "deadline") {
            std::cerr << "unexpected=" << error.what() << '\n';
            return 1;
        }
        caught_timeout = true;
        std::cout << "caught=deadline\n";
    }
    std::cout << "closed=" << observation.closed << " close-ok=" << observation.close_ok
              << " timeout=" << caught_timeout << '\n';
    return observation.closed == 3 && observation.close_ok &&
           caught_timeout == (mode == "timeout") ? 0 : 1;
}

读完整文件时,先按函数分别标出“配置标志”“等待就绪”“创建连接”“发送循环”“接收循环”“清理”,再从main追踪调用。非阻塞connect写就绪之后仍查SO_ERROR;等待中发生可重试条件时,保留原deadline;收发成功只按实际n推进。所有API的预测都使用前五节和本节刚建立的合同。

分别编译这份文件,再运行下面三个已声明的模式。normal应最终得到data=abcdef eof=1 capacity=2;one-byte只把接收容量改为1,最终数据仍相同。两种模式都应依次记录close=server、close=client、close=listener,每次result=0。timeout模式应先记录waiting=no-data,再按同样顺序关闭,随后caught=deadline,最终timeout=1。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror loopback-observer.cpp -o loopback-observer
./loopback-observer
./loopback-observer one-byte
./loopback-observer timeout

不要要求每次运行打印相同端口或相同recv行数。容量1时,每个成功recv只能提供1字节;容量2时可以是1或2。三种模式在观察符合各自预期时都返回0,错误的连接、内容、异常原因或关闭结果会失败。

忠实理解旧程序的两个边界

旧24-b的wait_ready在poll前计算剩余毫秒,剩余量不大于0就抛出;poll返回0也直接抛出,返回就绪就返回。正的不足1毫秒可能先被取整为0,所以会提早结束;系统调度也可能让返回越过终点,且旧函数没有在就绪后再检查时钟。因此它不是“函数返回时绝不越过deadline”的严格保证。这里固定2秒预算下,转换成int不是一个实际的超大超时溢出案例,不应凭想象给它添加这个故障。

原24-b和本页正常收发实验保持接收端有效,未请求send的额外信号处理标志。若主动提前关闭读端,再向失效连接写入,可能触发SIGPIPE并终止进程;不能把这种进程退出记成我们catch到的普通异常。本页不通过随意断开对端来声称“所有错误路径已验证”。macOS与Linux对信号抑制等扩展也有各自接口,当前实验不把一边的运行结果当作另一边的结果。

用一张表收尾

独立记录一次真实运行:每个fd的取得与关闭、每次send/recv的n、累积偏移、EOF何时出现。随后离开源码复述:为什么L与S不同,为什么poll返回1不是收了1字节,为什么SO_ERROR要单独查,为什么重试不能重置终点,为什么只复制buffer里实际有效的n字节。

能够回答这些问题,再进入第28章的完整网络示例与它的消息分帧、超时和背压任务。读完这一页不等于实现了TCP协议栈,也不会自动增加个人项目或面试验收记录。

轮到你独立完成

先保存自己的预测和实现,再按需展开提示与答案。

练习1

非阻塞connect返回-1且errno=EINPROGRESS;随后poll报告可写。有人立即打印connected。补全到可以确认连接成功的判断,并解释getsockopt返回值与写入error的两个0分别表示什么。

查看提示
  1. 可写说明操作可以继续检查,不等于连接一定成功。
  2. 读取SOL_SOCKET/SO_ERROR,分别检查系统调用本身和输出的待决错误。
对照答案与推理

在可写之后,用int error{0}与socklen_t length{sizeof error}接收getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&length)。只有getsockopt返回0且error==0才确认本次连接完成;前者是读取选项成功,后者是没有连接错误。调用失败或输出非零均走失败路径。EINPROGRESS仅表示开始等待,不能当成功也不能直接当最终失败。

练习2

长度前缀已另行解析为6,当前正文偏移0。给定正文返回2、1、3,对应字符ab、c、def;列出每次偏移、完整内容何时成立。若只收到2后返回0呢?此序列能否当成本章容量2的真实recv记录?

查看提示
  1. 长度前缀在本题是已提供的整数,不要求在这里实现编码解析。
  2. 每次只增加实际正返回量;EOF与预期长度同时检查。
对照答案与推理

偏移依次2、3、6;第三次才获得完整abcdef。若收到2后返回0,正文少4字节,应报告截断,不能把ab当完整消息或继续忙等。下一条消息需要新的协议状态,不能从poll次数推断边界。本题3字节返回属于独立给定模型;容量2的recv一次不可能返回3,不能把模型写进真实运行证据。

把理解说出来

以下问题应用本页已讲授的合同;依据技能主题编写,并非公司内部真题。

拥有关系与追问

accept得到的server替换listener了吗?shutdown(SHUT_WR)后client的Fd析构是否已经执行?

对照推理与英文回答

没有。accept返回新的连接描述符,listener仍可监听;本例三个Fd分别拥有。shutdown只关闭发送方向,Fd仍在作用域内,析构在之后的正常退出或受控展开时调用close。

Accept returns a new connected descriptor; it does not replace the listening descriptor. Shutdown of the write direction does not destroy the client owner or close its descriptor. Each owner later calls close independently.

接口参数与预测

sin_port设置htons(0)后为何还要getsockname?length为何既要先赋值又要传地址?

对照推理与英文回答

0请求系统选端口;bind成功后要getsockname读出实际地址,再ntohs显示端口。length先说明address缓冲容量,函数通过其地址写回实际地址长度。本例容量足够并核对返回长度;不能把输入0当实际端口。

Port zero asks the system to select a port. Getsockname retrieves the bound address. The length parameter first states the available buffer size and then receives the actual address length. Convert the returned port with ntohs for display.

找错与状态机

connect返回EINPROGRESS,之后可写,是否足以成功?poll返回1是否意味着读到1字节?

对照推理与英文回答

均不是。可写后仍用getsockopt读取SO_ERROR,要求调用成功且error为0。poll正返回值数的是revents非零的条目,实际字节数来自send/recv的返回。

Neither conclusion follows. After writable readiness, inspect SO_ERROR and check both the getsockopt result and the reported error. A positive poll result counts ready entries; send and recv report actual byte progress.

EOF与读码

POLLIN和POLLHUP能否共存?buffer容量2能否保证每次读ab、cd、ef?

对照推理与英文回答

能共存,挂断时仍可能有可读数据,应继续recv至0。容量2只是每次接收上限,可能收到1或2,不固定切分;按n复制有效字节。此例关闭发送方向后读至EOF,不由就绪事件获得应用层边界。

Readable data can remain when hang-up is reported. Drain the stream until recv returns zero. A capacity of two is an upper bound, not a promise of two-byte chunks. Append exactly the positive byte count returned.

时间预测与限制

2000ms终点、1500ms重试还剩多少?正500μs转整数毫秒是多少?原24-b就绪返回是否保证尚未过期?

对照推理与英文回答

还剩500ms;500μs截断为0ms。原程序在poll前检查,poll超时即抛错、就绪即返回,可能因取整提前超时,也可能因调度返回时已越过终点。观察版增加poll后取样,但也不是实时返回保证。

The original deadline leaves five hundred milliseconds. Five hundred microseconds truncates to zero integer milliseconds. The original code only checks before polling, so rounding can cause early failure and scheduling can cross the deadline. A later time check improves the observation, not real-time scheduling guarantees.

验证边界与追问

为何原main异常退出不能证明Fd析构跑过?本桥正常传输通过后,能否直接测试任意提前关闭并预期catch捕获?

对照推理与英文回答

未捕获异常进入terminate前是否展开栈由实现决定,操作系统回收描述符不等于C++析构。受控例把throw放在外层catch覆盖的内层作用域,真实记录close返回。正常发送保持对端活;原例和本观察例都未防SIGPIPE,提前关闭可能终止进程,不能声称会被C++catch捕获。

For an uncaught exception, unwinding before terminate is implementation-defined; operating-system cleanup is not evidence of C++ destructors. The controlled case catches outside the owning scope and records close results. These runs keep the peer alive and do not suppress SIGPIPE, so arbitrary premature closure is outside the tested contract.

同源示例下载

按正文指定条件逐个编译。接口教学模型与实际系统调用分别说明,不把模型输出当作真实I/O或设备执行。

资料与查证