English Version | 中文版本
这篇文章展现了基于c的网络编程,代码作为理解设计思想的必要,更重要的是体现网络中的设计哲学,我更建议你的是手敲代码,根据这份readme所提供的信息来尝试,为了更清晰的展现网络结构,所有的代码就只是对应功能的简单实现。相信你在这段过程中,可以去体悟到网络设计的底层实现。现在我主要进行的是c的tcp和udp的简单实现,后续会加入对数据报文,ip协议栈,和对go的底层设计的接口分析。尽管并不是自顶向下的构成分析,但是从对一个网络初学者而言,这是不错的起点
本项目按照学习路线分为以下几个模块:
- 01 Basic (基础概念)
- 02 UDP Socket (UDP 通信)
- 03 TFTP Implementation (TFTP 协议实现)
- 04 Broadcast & Multicast (广播与多播)
- 05 TCP Socket (TCP 通信)
- 01 The Goal of This Part(本节目标)
- 02 Sample Code Dissection & netpoll Internals Overview(示例代码具体拆解与底层
netpoll机制概览) - 03 listen Function Internals(listen函数的内部调用)
- 04 The Netpoll Architecture(netpoll的网络体系)
- 05 Underlying implementation of Accept(accept-的底层实现)
网络通信的基石,主要解决不同层次上的数据表示差异。
-
01_endian (字节序)
- 展示了计算机 小端存储 (Little-Endian) 与 大端存储 (Big-Endian) 的区别。
- 为什么会有这种存储的差异性:这纯粹是CPU架构的历史遗留问题(比如 Intel x86 选了小端,而早期的 Motorola 选了大端)。但为了防止乱套,网络协议强行规定了必须用大端作为网络字节序。所以我们在发包前必须老老实实把主机的小端序转过去。
-
02_htol_htons (字节序转换)
- 基于
<arpa/inet.h>头文件。extern uint16_t htons (uint16_t __hostshort) __THROW __attribute__ ((__const__));
- 实现 主机字节序 (Host) 向 网络字节序 (Network) 的转换 (如
htonl,htons)。 - 解释一下
__THROW和__attribute__:这其实是写给编译器看的tip。__THROW告诉编译器这函数绝不抛出异常,__const__告诉编译器这函数是“纯函数”(只依赖输入,没副作用)。这样编译器就能大胆地做优化,把多余的调用给省掉。
- 基于
-
03_inet_pton (IP地址转换)
- 全称 Presentation to Numeric。
- 将点分十进制字符串 (如 "192.168.1.1") 转换为网络传输用的 32位无符号整数。
int inet_pton (int __af, const char *__restrict __cp, void *__restrict __buf) __THROW;
- 为什么需要一个 void 类型:这里设计得很巧妙,因为 IPv4 用
struct in_addr(4字节),IPv6 用struct in6_addr(16字节)。用void*就能像万能插头一样,不管你是哪种协议,都能把转换后的二进制数据填进去。
-
04_inet_ntop(IP地址还原)
- 全称 Numeric to Presentation。
- 将 32位网络字节序整数还原为人类可读的 IP 字符串。
extern const char *inet_ntop (int __af, const void *__restrict __cp, char *__restrict __buf, socklen_t __len) __THROW;
extern意味着这是个外部引用,__len则是为了防止缓冲区溢出(C语言老生常谈的内存安全问题),这一部分被认为是add部分单独开一行。
无连接的、不可靠的数据传输协议。
-
- 展示了socket套接字创建的函数
int socket (int __domain, int __type, int __protocol)
- domain决定IP类型,type则是决定tcp还是udp的传输类型。protocol是具体的协议格式
- socket是一个int类型,靠文件描述符的抽象在系统层面上实现调用,展现了linux中一切皆文件的设计思想
- 作为文件描述符,一定要在程序最后对其进行关闭,close在通信过程中就意味着断开连接,在tcp中,这一点变得更为复杂
- 展示了socket套接字创建的函数
-
-
展示了udp传输类型的数据发送
ssize_t sendto (int __fd, const void *__buf, size_t __n,int __flags, __CONST_SOCKADDR_ARG __addr,socklen_t __addr_len);
-
ssize_t 是个啥:其实就是
signed int。因为这函数成功时返回发送字节数(正数),失败要返回 -1。如果用普通的size_t(无符号),就没法表示 -1 这个错误状态了。 -
adding
-
在这里进行数据传输的时候通过addrsocket_in记录和写入ip和port
struct sockaddr_in { __SOCKADDR_COMMON (sin_); in_port_t sin_port;/* Port number. */ struct in_addr sin_addr;/* Internet address. */ /* Pad to size of `struct sockaddr'. */ // ... padding ... };
-
需要注意的是,尽管我们写入的是sockaddr_in但是封装函数写入的确实sockaddr结构体
struct sockaddr { __SOCKADDR_COMMON (sa_); /* Common data: address family and length. */ char sa_data[14]; /* Address data. */ };
-
可以看到,这实际上是进行了一个数据压缩的过程,这样的设计展现了编程最核心的问题,自然语言编程和机器二进制构成的矛盾
-
-
-
bind这个函数主要是为了固定ip和端口号,根据这个函数的面向我们可以很容易理解信息的接收方更加需要这个需求。
int bind (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len) __THROW;
-
在tcp/udp编程时我们一般简单的就把server称为需要绑定的一方,但这实际上是由于信息接受和发送的相对关系所决定的,在多播和组播中我们能够看到这一点的进一步体现
-
-
-
recvform是udp接受函数
recvfrom (int __fd, void *__restrict __buf, size_t __n, int __flags, __SOCKADDR_ARG __addr, socklen_t *__restrict __addr_len)
-
这里需要注意的是recvfrom是接受别的主机的数据,所以我们需要预先创建空的结构体供其填入,并且还改变addrlen来作为接受到的信息长度输出,这种设计使得udp可以很简单的实现多线程工作,代价就是每一个client都需要相应的结构体来对应,而我们将会在后续看到,因为tcp要求的三次握手,导致tcp的设计走向了截然不同的道路
-
-
-
这一段是具体的udp的客户端和服务端的运行代码。本质上就是调用上述函数具体实现通信过程
if(argc<3){ fprintf(stderr,"Usage : %s<IP> <PORT>\n",argv[0]); exit(1); }
-
这一段保证了运行程序时输入了ip和port,这里需要注意的是,client里面输入的也是server的ip和port,因为client根本不需要在乎自己,他只需要保证数据交互
if(recvfrom(sockfd,buf,sizeof(buf),0,(struct sockaddr*)&clientaddr,&addrlen)==-1){ perror("fail to recvfrom"); //即时接受失败也可以继续进行 continue; }
-
这里体现了recvfrom的核心用处,是udp传输中的核心所在,通过接受数据,将数据发送主机的ip记录下来,用来进行sendto操作,这种设计使得udp的多client能极为容易实现,尽管在实际执行写起来的时候稍微有点冗余。
-
-
udp通信交互流程简图:
[Client] [Server]
| |
|--- sendto(Data, ServerIP) ----->|
| | recvfrom (获取 ClientIP)
| |
|<-- sendto(Echo, ClientIP) ------|
| |
recvfrom(Echo)
- adding
-
我们这里的输入输出主要使用fgets和printf方法
extern char *fgets (char *__restrict __s, int __n, FILE *__restrict __stream) __wur __fortified_attr_access (__write_only__, 1, 2) __nonnull ((3));
-
缓冲区的大坑:在c中string以'/0'作为在内存中的数据界限,而fgets则会将换行符计入,如果为了数据的纯洁性应该将换行符去掉,但是对于printf中来说只有遇到换行符才会将数据从缓冲区中打印出。所以这两个函数搭配时不需要做什么处理。
-
Trivial File Transfer Protocol (简单文件传输协议)。
-
begin
- 在开始之前我想简单的介绍一下理解tftp的核心所在,作为基于udp协议的小文件传输协议,在c中实现tftp最让人恼火也最为关键的就是手动构建和分析二进制报文,你需要去拼凑每一个字节。
-
Part 1: 报文构造区
-
明确下载文件名
scanf("%s",filename); -
第一个难点是去构造数据报文,这玩意不是字符串,是紧凑的二进制。
-
TFTP 二进制报文结构图:
2 bytes string 1 byte string 1 byte ------------------------------------------------ | Opcode | Filename | 0 | Mode | 0 | ------------------------------------------------ -
代码里用了一个很巧妙的操作
sprintf来拼接:packet_buf_len = sprintf((char*)packet_buf,"%c%c%s%c%s%c",0,1,filename,0,"octet",0);
-
解释一下字节序:这里为什么不需要在意大端存储和小端存储的转换?因为
sprintf是按顺序写入单字节的。写入 0 再写入 1,内存里就是00 01,这恰好符合网络字节序的大端要求。
-
-
Part 2: 接收与解析循环 (State Machine)
-
packet_buf 用来接受server发送来的数据,这里发送数据全部使用unsigned char 类型来进行发送,而通过数据来储存这个数据,意味者可以简单直接的通过使用这个数组来对数据包头进行解析
//这里是数据报文的传输层级 unsigned char packet_buf[1024]= "";
//错误信息 if(packet_buf[1]== 5){...exit(1)} //收到server正确的反馈请求 if(packet_buf[1]==3){//进行下一步处理}
-
需要注意的是首先要去判断是否存在相应文件,可以用bool或int类型数据来标识,如果没有则需要先创建相应文件
-
-
Part 3: 验证与ACK (手动可靠性)
-
另一个难点就是接受核对数据保重的区块编号,因为udp是不可靠的连接,所以说需要手动去验证是否存在数据丢失。我们需要从数据报头中读取并和本地记录的进行比对。
-
数据验证流程:
if((num +1) == ntohs(*(packet_buf+2))) //success -> 发送ack报文 //fail -> 数据丢失,推出
-
如果当前数据包没问题,需要构建ack报文发送给server,来让他来发送下一块数据。这里必须用
ntohs,因为包头里的序号是网络序,得转成本地序才能对比。packet_buf[1]= 4;
-
这里尽管发送了文件块数据,但是server只需要对数据包头进行验证
-
如果数据块小于516,即文件数据小于512,那么说明写入结束,但在这里没有去考虑文件大小刚好是512倍数的情况
-
总结:这样一个客户端主要的难题就是二进制报文的处理,因为udp本身是不可靠的,所以我们就需要手工做数据包做数据包头进行验证,我们可以看到的是,这样一个验证思路实际上和tcp的三次握手非常相像,所以一般由tcp承担文件传输的工作
-
-
-
服务端逻辑相对被动,主要是解析和反馈:
-
验证数据报文和是否由相关文件
-
自定义区块num并写入包头,用缓冲区作为文件数据中转
-
等待并解析ack数据包
-
-
构造错误包 (辅助函数) 这里需要注意的是一个构建错误数据包的函数,为了代码整洁抽出来的:
void senderr(int sockfd,struct sockaddr* clientaddr,char* err,int errcode,socklen_t addrlen){ unsigned char buf[516] = ""; // 构造错误包: [00] [05] [00] [ErrCode] [ErrMsg] [00] int buf_len = sprintf((char*)buf, "%c%c%c%c%s%c", 0, 5, 0, errcode, err, 0); sendto(sockfd,buf,buf_len,0,clientaddr,addrlen); }
-
本身这样的函数使得我们可以迅速的在逻辑末节点(比如文件打不开)发送相关信息。
-
总结:这里服务端展现的设计思想基本与客户端一致,这里需要注意的是两边都在本地储存了区块号,双方都对数据报文进行了解析
-
-
background * 在这里我们首先要去介绍一个函数
setsockopt。extern int setsockopt (int __fd, int __level, int __optname, const void *__optval, socklen_t __optlen) __THROW;
-
这个函数的作用是在文件描述符的基础上对其做进一步的限制说明。
-
参数详解:
-
__fd:socket 的文件描述符。 -
__level:选项定义的层次。通常设为SOL_SOCKET(通用套接字选项) 或IPPROTO_IP(IP层选项)。 -
__optname:具体要设置的选项名。例如SO_BROADCAST(允许广播)、SO_REUSEADDR(端口复用)。 -
__optval:指向存放选项值的缓冲区的指针。通常是一个int类型的指针,1表示开启,0表示关闭。 -
__optlen:optval缓冲区的长度。 -
在server中,我们也可以将其设置为非端口复用模式来方便调试,但为了代码的简便性,我在代码实例中并没有添加这部分内容。
-
端口复用代码实例:
int opt = 1; // 允许重用本地地址和端口,解决 "Address already in use" 错误 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
-
-
- 这个文件展现的是 broadcast 的发送方。和 tcp,udp 编程不同,在广播和多播里并没有传统意义上的 cs 框架,而是信息发送和接受的相对关系。
- 这里采用 sendto 函数,除了需要额外对 socket 做功能添加外,基本上和 udp 的 client 思路一致。
-
- 这个文件的结构甚至比 udp_server 的结构还更加简单,因为这里广播地址是确定的,只需要监听是否有对应的数据包即可。
- 这里有意思的是 recv 里面并不需要设置对应权限,这也和广播的设计思路相一致,广播的发送方需要额外的检验,而接收方只需要判断这个数据包是不是找自己的。
-
summary
- 广播的实现是基于 udp 完成的,因为广播本身就是一个一对多的单向过程,在实际网络过程中常常伴随着多次广播,所以说在这里数据的快速发送的重要性要远大于数据的稳定传输。
- 广播的设计哲学:广播类似于“大喇叭喊话”。因为这种行为会占用整个子网的带宽,可能造成扰民(网络风暴),所以内核设计上要求发送者必须显式调用
setsockopt(SO_BROADCAST)来申请权限(打开开关)。而接收者是被动的,不需要特殊权限就能听到。
-
adding (IP Class Knowledge)
- 理解多播需要先学习 IP 分类知识:
- A/B/C 类:用于单播 (Unicast),即一对一通信。
- D 类 (224.0.0.0 ~ 239.255.255.255):专用于多播 (Multicast)。这部分 IP 不属于任何一台具体的主机,而是代表一个“组”。向这个 IP 发送数据,所有加入了这个组的主机都能收到。
- E 类:保留科研用。
-
- 在这里组播的发送方甚至连
setsockopt都不用使用,这是因为本身有 D 类 IP 段被划分成专用于组播。所以 send 只需要向这些 ip 段里面发送数据,当它进行发送的时候,实际上就已经在对应 ip 设置了对应的广播组。
- 在这里组播的发送方甚至连
-
adding
- INADDR_ANY 是什么:在代码中常常见到
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);。它的数值其实是0.0.0.0。它的意思是“绑定到本地所有可用的网络接口”。如果你既有 Wifi 又有网线,使用INADDR_ANY可以让你从两个网卡都能接收到数据,而不需要把程序绑定死在某一个具体的 IP 上。
- INADDR_ANY 是什么:在代码中常常见到
-
-
recv 中需要使用
setsockopt进行设置。在之前所说,setsockopt 中的_optval是void*类型,这也意味着我们可以构造结构体进行数据传参,这也是我们在 c 中常用的方法。而这里我们需要采用的是专门为了多播组设置的结构体ip_mreq进行参数设置:struct ip_mreq { /* IP multicast address of group. */ struct in_addr imr_multiaddr; // 多播组的IP (比如 224.0.0.88) /* Local IP address of interface. */ struct in_addr imr_interface; // 自己加入该组的接口IP (通常用 INADDR_ANY) };
-
这里
imr_interface是本地接口,imr_multiaddr是组播 IP,其下都有s_addr成员,和sockaddr_in的设计一样,都是因为历史原因导致。 -
summary (Broadcast vs Multicast Philosophy)
-
这里和广播需要做出明确划分,这也体现了两者底层逻辑的截然相反:
-
广播 (Broadcast):是发送方需要
setsockopt。因为广播是暴力的,默认禁止,发送者必须主动申请“我要喊话”的权限。 -
多播 (Multicast):是接收方需要
setsockopt(加入组IP_ADD_MEMBERSHIP)。因为多播是精准的,发送方只是往一个 D 类 IP 发数据(谁都可以发),关键在于接收方必须显式地声明“我订阅了这个频道”,内核才会把对应的数据包捞上来给你。
-
-
background
- 尽管在这里 tcp 和 udp 的最大区别是 tcp 有了三次握手四次挥手来保证数据传输,但我们在调用函数进行编程时,这些复杂的状态流转大多已被内核封装。换句话说,我们在这里更多是从应用层的角度去考虑 socket 的生命周期管理。
- 设计哲学的转变:UDP 是无状态的,一个 socket 可以给任意 IP 发包;但 TCP 是面向连接的,就像打电话,必须先接通才能说话。这种设计要求服务端必须维持一个“监听 socket”专门用来接客,每来一个客人(客户端),就得新建一个“服务 socket”专门负责聊天。
- 并发的核心矛盾:如何高效地管理这些成百上千的“服务 socket”?这就派生出了两条技术路线:
- 多进程/多线程:通过增加人手(CPU调度单元)来解决,一个连接对应一个线程/进程。
- IO 多路复用 (Non-blocking):通过非阻塞 IO + 事件轮询(如 epoll),让一个服务员(单线程)就能看管所有桌子。
-
-
这里展现的是 tcp 的客户端,在创建好 socket 和封装好 server 结构体后,我们首先要调用封装好的函数去建立底层连接。
extern int connect (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len);
-
Connect 的底层机制 (三次握手触发器):
- 当调用
connect时,内核会向 Server 发送一个 SYN 包。 - 此时函数处于阻塞状态,等待 Server 回复 SYN+ACK。
- 收到回复后,Client 再发送一个 ACK,此时连接建立 (ESTABLISHED),函数返回 0。
- 当调用
-
在 client 这一方通常只需要维护一个 socket,建立连接后,内核已经把这个 socket 绑定到了特定的远端 IP 和端口,所以
send函数不需要像sendto那样重复指定目标地址。extern ssize_t send (int __fd, const void *__buf, size_t __n, int __flags);
-
-
adding (Buffer Trap)
-
strlen vs sizeof 的大坑:发送字符串时,**千万不要用
sizeof(buf),要用strlen(buf)。 -
原因:
sizeof计算的是数组申请的总内存(比如 1024),而strlen计算的是实际字符长度(比如 "hello" 是 5)。如果你用sizeof,你会把缓冲区里后面几百个没用的乱码(垃圾数据)也发给对方,这在处理协议时是灾难性的。
-
-
-
这里是 tcp 服务器的实例。在创建好 socket 和填充绑定好结构体后,首先要将 socket 设置为监听状态。
extern int listen (int __fd, int __n) __THROW;
-
__fd: 之前创建的套接字文件描述符。 -
__n: Backlog (积压队列长度) -
为什么需要 Listen:
-
内核为监听套接字维护了两个队列:半连接队列 (收到 SYN 但没收到最终 ACK) 和 全连接队列 (三次握手完成等待 Accept 取走)。
-
__n实际上决定了这些队列(通常是全连接队列)的大小。如果队列满了,新的连接请求就会被直接丢弃或拒绝(SYN Flood 攻击也是针对这里)。 -
设置好监听状态后,通过
accept从全连接队列中取出一个已完成的连接。extern int accept (int __fd, __SOCKADDR_ARG __addr, socklen_t *__restrict __addr_len);
-
两个 FD 的故事:
-
accept返回的int是一个全新的文件描述符 (Connected Socket)。 -
设计哲学:原来的
sockfd只负责把人领进门;accept返回的fd专门负责这一桌的通信。这种分离设计使得 TCP Server 可以同时处理握手请求和数据传输。 -
Recv 的返回值判断:
extern ssize_t recv (int __fd, void *__buf, size_t __n, int __flags);
-
> 0: 接收到的字节数。 -
= 0: 重要! 这代表对端关闭了连接 (FIN 包)。TCP 是全双工的,0 字节读意味着 Read 通道关闭。 -
< 0: 出错 (Error),需要检查 errno。 -
而在dup中可以直接发送长度为0的数据包
-
summary (CS Framework)
-
-
TCP C/S 交互流程图:
[Server] [Client] socket() socket() | | bind() | | | listen() | | | accept() <---(3-Way)---> connect() (Block...) Handshake | | | recv() <----(Data)----- send() | | send() ----(Data)----> recv() | | close() <----(4-Way)---> close() Wavehand -
-
这里是通过多进程的方式来实现并发。
extern __pid_t fork (void) __THROWNL;
-
Fork 的魔法:调用一次,返回两次。
-
返回
> 0(子进程 PID):当前是父进程,任务是继续accept等待新人。 -
返回
0:当前是子进程,继承了父进程的所有资源(包括 socket),任务是处理刚刚那个连接的send/recv。 -
COW (Copy On Write):Linux 这里的效率很高,并不会真的立马把父进程所有内存复制一份,只有当子进程尝试修改数据时,才会真正复制内存页。
-
僵尸进程与信号回收:
-
子进程结束时如果父进程不管,它会变成“僵尸进程”占用 PID 资源。
-
我们利用
signal机制来异步回收。// 注册信号处理函数 signal(SIGCHLD, handler); void handler(int sig){ // 循环回收所有已结束的子进程 while((waitpid(-1, NULL, WNOHANG)) > 0){} }
-
Waitpid 参数详解:
-
-1: 等待任意子进程。 -
NULL: 不关心子进程具体的退出状态码 (exit code)。 -
WNOHANG: 非阻塞关键。如果当前没有子进程结束,立刻返回 0,不要卡在这里傻等。这保证了 Server 不会因为回收垃圾而停止响应新请求。
-
-
-
使用多线程处理。进程是资源分配的单位(重),线程是 CPU 调度的单位(轻)。
extern int pthread_create (pthread_t *__restrict __newthread, const pthread_attr_t *__restrict __attr, void *(*__start_routine) (void *), void *__restrict __arg) __THROWNL __nonnull ((1, 3));
-
参数详解:
-
__newthread: 指向线程 ID 的指针,用于接收新线程 ID。 -
__attr: 线程属性,通常传NULL使用默认值。 -
__start_routine: 线程启动后要执行的函数指针。 -
__arg: 传给启动函数的唯一参数。由于只能传一个,所以通常需要把 socket、IP 等信息打包成结构体,转为void*传入。 -
编译指令:
gcc server_thread.c -o server -lpthread
-
自动垃圾回收 (Detach):
pthread_detach(pthread_self());
-
原理:默认情况下线程是
joinable的,退出后需要主线程调用pthread_join来“收尸”。调用detach是告诉内核:“这个线程也是个普通打工人,死了直接埋了就行”,内核会在线程退出时自动释放其栈空间和资源,无需主线程操心。
-
-
-
在这个文件里面我们尝试将 socket 设置为非阻塞 (Non-blocking)。这是迈向高性能 IO (Epoll/IOCP) 的第一步。
// 获取当前 flag int flag = fcntl(sockfd, F_GETFL, 0); // 设置新 flag = 旧 flag + 非阻塞位 fcntl(sockfd, F_SETFL, flag | O_NONBLOCK, 0);
-
位运算图解:
-
fcntl通过位掩码来管理状态。 -
flag(假设):0000 0010(代表已有的属性) -
O_NONBLOCK:0000 0100(非阻塞属性) -
|(OR) 操作:0000 0110(同时拥有两种属性) -
非阻塞的代价 (Errno):
-
当 socket 非阻塞时,如果
recv缓冲区里没数据,它不会卡住,而是立刻返回-1。 -
此时必须检查
errno。如果errno == EAGAIN(Try again) 或EWOULDBLOCK,说明**“现在没数据,不是出错了,待会再来”**。这使得程序可以在没数据时去干别的事。
-
-
-
Epoll: Linux 下最高效的 IO 多路复用器。它解决了
select/poll轮询所有 socket 效率低下的问题。extern int epoll_create1 (int __flags) __THROW;
-
创建一个 epoll 实例(红黑树根节点),返回句柄
epfd。struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ } __EPOLL_PACKED;
-
核心参数:
-
events: 感兴趣的事件。 -
EPOLLIN: 有数据可读 (包括新连接)。 -
EPOLLET: 边缘触发 (Edge Triggered)。数据这就只有一次通知,没读完下次不提醒(高效但难写)。默认是 LT (Level Triggered),没读完一直提醒。 -
data:data里面有多种数据结构,这里我们使用文件描述符 -
data.fd: 记录是哪个 socket 发生了事件。extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;
-
操作类型 (
__op): -
EPOLL_CTL_ADD: 注册新的 socket。 -
EPOLL_CTL_MOD: 修改监听事件。 -
EPOLL_CTL_DEL: 移除 socket。extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout)
-
Event Loop 逻辑:
-
epoll_wait阻塞等待,一旦有 socket 就绪,它会将这就绪的 socket 填入__events数组并返回数量n。 -
我们只需要遍历这
n个活跃的 socket,而不需要遍历所有 10000 个 socket。 -
分流处理:
-
如果
events[i].data.fd == listen_fd: 说明有新连接 -> 调用accept->epoll_ctl(ADD)加入监控。 -
否则: 说明是已连接的客户端发数据了 -> 调用
recv/send处理业务。
-
-
adding
- 总结:Epoll 用单线程实现了高并发,避免了多线程频繁切换上下文的开销 (Context Switch)。但如果业务逻辑非常耗时(比如计算密集型),单线程会被卡死。
- Go 的伏笔:Go 语言的 Goroutine 实际上就是将“多线程的易用性”和“Epoll 的高性能”结合了起来——底层用 Epoll 监听,上层用轻量级协程伪装成阻塞 IO,我们将在后续部分看到这种天才般的设计。
-
在上一节中,我们探讨了 C 语言中
netpoll(网络轮询)的底层实现。而在本节,我们将把目光转向 Go 语言,深入剖析 Go 语言中netpoll的实际应用与巧妙设计。在此之前,如果你对“程序”与“进程”等基础概念还不够熟悉,我强烈推荐你观看 [Core Dumped 的这期科普视频](https://www.youtube.com/watch?v=7ge7u5VUSbE [00:46]),以此来巩固必要的计算机底层知识。 -
下面是一段典型的 Go 语言网络编程代码。通过剖析这段代码的运行机制,我们将逐步揭开 Go 语言底层网络模型的实现原理。
package main import ( "fmt" "net" ) func main() { // 1. 监听本地的 8080 端口 listener, err := net.Listen("tcp", ":8080") if err != nil { panic(err) } defer listener.Close() fmt.Println("Server is running on :8080...") for { // 2. 阻塞等待新的客户端连接 conn, err := listener.Accept() if err != nil { fmt.Println("Accept error:", err) continue } // 3. 为每个连接开启一个独立的 Goroutine 进行处理 go handleConnection(conn) } } func handleConnection(conn net.Conn) { defer conn.Close() buf := make([]byte, 1024) for { // 4. 读取客户端发送的数据 n, err := conn.Read(buf) if err != nil { fmt.Println("Connection closed or read error") return } // 5. 将读取到的数据原样写回(Echo Server) _, err = conn.Write(buf[:n]) if err != nil { fmt.Println("Write error:", err) return } } }
这段代码虽然看起来是非常简单的“同步阻塞”风格,但得益于 Go 语言运行时的封装,它在底层其实是非常高效的异步非阻塞 I/O。下面我们结合 netpoll 来逐一拆解:
- 表面逻辑:创建一个 TCP 监听器,绑定在 8080 端口。
- 底层机制:在这个阶段,Go 运行时不仅仅是调用了系统底层的
socket()和bind()函数。更重要的是,它会将这个监听的 Socket 设置为**非阻塞(Non-blocking)**模式,并将其文件描述符(FD)注册到操作系统的事件轮询器中(例如 Linux 的epoll,macOS 的kqueue)。这就是 Go 中netpoll机制的入口。
- 表面逻辑:程序运行到这里会“卡住”(阻塞),直到有新的客户端连接进来。
- 底层机制:由于底层的 Socket 是非阻塞的,如果没有新连接,底层的
accept系统调用会直接返回错误(如EAGAIN)。此时,Go 的netpoll机制就会介入:它会将当前的 Goroutine 挂起(Park),释放 CPU 线程去执行其他任务。直到底层的epoll监听到该端口有新的连接到达时,netpoll才会**唤醒(Ready)**这个挂起的 Goroutine 继续向下执行。这种设计让单核 CPU 也能支撑极高的并发等待。
- 表面逻辑:获取到新连接后,启动一个新的协程去专门服务这个客户端,主循环继续回去执行
Accept()等待下一个人。 - 底层机制:这是 Go 网络编程最核心的设计模式。相比于 C/C++ 中需要手动编写复杂的回调函数或状态机来处理并发,Go 通过极轻量级的 Goroutine(初始只占 2KB 内存)实现了简单的并发。成千上万个连接对应的就是成千上万个 Goroutine,由 Go Scheduler(调度器)高效调度。
- 表面逻辑:在独立的协程中,持续循环读取客户端发来的数据。如果没有数据发来,
Read就会阻塞。 - 底层机制:这里的阻塞逻辑与
Accept()完全一致。当缓冲区没有数据可读时,该读操作会触发 Go 调度器将当前的handleConnectionGoroutine 挂起,并将这个 Socket 注册到netpoll中。当客户端真正发来网络数据,操作系统网络栈接收完毕后,epoll触发事件,Go 的后台网络轮询线程就会把这个 Goroutine 重新放入可运行队列中,代码随即从conn.Read处“苏醒”并继续执行。
总结:这段代码完美展示了 Go 语言设计的巧妙之处——用最简单的同步代码逻辑,写出了底层由 epoll + Goroutine 驱动的高性能异步非阻塞服务器。开发者无需关心复杂的文件描述符轮询和状态机切换,所有的“脏活累活”都被封装在了 Go 的运行时网络多路复用器(netpoll)中。而在接下来的步骤中,我们将自上而下去拆解这整个逻辑架构。
-
如果我们沿着
listen函数一路深入,会发现核心入口是 socket 函数。这个函数是 Go 底层创建 socket 的实例,和我们之前在 C 语言中调用的socket()系统调用一样,它也会返回一个相应的文件描述符(File Descriptor)。// 核心逻辑简述 s, err := sysSocket(family, sotype, proto) // ...... err = setDefaultSockopts(s, family, sotype, ipv6only) // ...... // 根据 socket 类型分发逻辑 switch sotype { case syscall.SOCK_STREAM, syscall.SOCK_SEQPACKET: // 如果是流式套接字(TCP),进入 listenStream if err := fd.listenStream(ctx, laddr, listenerBacklog(), ctrlCtxFn); // ...... }
-
底层交互与自举:在这里我抽出了
sysSocket这一核心逻辑。它调用的是底层封装的汇编命令。值得一提的是,Go 区别于 PHP 等解释型语言的一个显著特征在于 Go 是自举(Self-hosted)的——Go 语言本身也是由 Go 编写的。这意味着 Go 拥有自己的汇编代码和对应的cmd编译目录。当我们追溯 Go 底层时,可以清晰地看到 Go 代码与汇编代码的交界处。 -
参数配置:宏观上看,这个函数相当于我们在 C 中配合使用的
socket()和setsockopt()。sysSocket负责创建,而setDefaultSockopts负责设置基础属性。 -
角色定型:完全体的 Socket(是作为客户端还是服务器?是流式传输还是数据包?)最终通过上层传参确定。在本例中,区别于客户端的
Dial,Listen操作通过绑定本地端口(如代码中的8080),明确了当前机器作为服务端的角色。
-
在函数内部,创建流式套接字(TCP)和数据包套接字(UDP)被封装成了不同的路径。以我们关注的流式套接字为例,看看 listenStream 做了什么:
// 1. 设置监听器的默认 Socket 选项 setDefaultListenerSockopts(fd.pfd.Sysfd) // ... // 2. 将地址转化为系统识别的 sockaddr 结构体 // ... // 3. 应用用户自定义的 Socket 属性 // ... // 4. 绑定端口 (对应 C 中的 bind) syscall.Bind(fd.pfd.Sysfd, lsa) // 5. 开始监听 (对应 C 中的 listen) listenFunc(fd.pfd.Sysfd, backlog) // 6. 初始化文件描述符(关键步骤!) fd.init()
-
C 语言的影子:数据包套接字的创建逻辑类似,只是多了对多播地址的判断。可以明显看到,Go 的这一套流程完美复刻了我们在 C 代码中执行的
socket->bind->listen标准三部曲。这揭示了 Go 的网络底层依然是基于标准 OS 套接字机制的封装。
-
之前的步骤和我们在 C 中实现的逻辑别无二致,但从 fd.init 开始,我们将进入 Go 独有的魔法领域——Netpoll(网络轮询器)。
-
这个函数的主要功能是判断当前文件描述符是否属于网络文件(即非普通文件),如果是,则为它初始化网络轮询机制:
// 初始化 pollDesc (poll descriptor) fd.pd.init(fd)
-
init 函数是 Go
net标准库与runtime运行时包的关键交汇点,也是“同步代码、异步执行”的基石。func (pd *pollDesc) init(fd *FD) error { // 1. 保证全局网络轮询器只被初始化一次 serverInit.Do(runtime_pollServerInit) // 2. 将文件描述符注册到轮询器中 (底层对应 epoll_ctl/kqueue 等) ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd)) if errno != 0 { return errnoErr(syscall.Errno(errno)) } // 3. 保存上下文 pd.runtimeCtx = ctx return nil }
-
serverInit.Do:利用sync.Once机制,确保在整个程序生命周期中,全局的网络轮询器(Poller)只会被初始化一次。 -
runtime_pollOpen:这是最关键的一步。它将我们之前创建的 Socket 文件描述符(fd)注册到底层的 IO 多路复用器中(在 Linux 下即调用epoll_ctl添加EPOLLIN等事件)。如果注册失败,通常意味着系统资源耗尽或环境异常。
小结:
在这一部分,我们验证了 Go 底层的 Socket 封装在前半程与我们 C 语言实现 (socket-underlying-c) 的逻辑高度一致。
然而,分水岭出现在 fd.init 之后——Go 并没有止步于创建 Socket,而是通过 runtime 将其无缝接入了 Netpoll 体系。在接下来的部分中,我们将进一步探索这个围绕 Netpoll 建立起来的庞大网络处理体系,以及它是如何通过调度器与 Go 的多协程(Goroutine)完美结合的。
分层抽象与跨层级对象映射。
延续之前的思路,我们已经梳理了 internal/poll 包中的高层逻辑。现在,我们将视线由表及里,正式下沉到 Go 核心的 runtime 包中。
在跨越这个边界之前,必须重申一个核心机制,即我们在前文中提及的 pd.runtimeCtx = ctx 这一行看似不起眼的代码。这实际上是 Go 网络轮询器(Netpoller)设计的点睛之笔:
-
双面一体的结构:
internal包与runtime包中各有一个pollDesc结构体。它们在逻辑上是一一对应的,宛如一个实体的“两面”。 -
internal/poll.pollDesc:面向用户层,处理文件描述符的生命周期、读写超时等通用逻辑。 -
runtime.pollDesc:面向底层,承载着与操作系统内核交互(如 epoll/kqueue)的具体状态。 -
指针的“偷渡”与桥接:
pd.runtimeCtx = ctx这一操作,实质上是将runtime层级下的结构体指针(以uintptr这种通用且不被 GC 追踪的形式)“走私”并封装到了internal层级的结构体中。 -
层级解耦与业务偶联:这种设计使得上层
internal包在进行底层操作时,只需将这个“句柄”传回,runtime就能瞬间找回其对应的内核态上下文。Go 正是以这种非侵入式的方式,实现了不同层级业务的严格封装与必要时刻的高效偶联。
当我们追踪 netpoll 的初始化流程时,在 netpollGenericInit 中,我们可以看到一段非常经典的并发控制代码。为了确保在多线程高并发场景下 netpoll 只被初始化一次,Go 采用了 Double-Checked Locking(双重检查锁定) 模式:
- First Check(无锁检查):首先利用原子操作(Atomic Load)快速判断
netpollInited标志位。如果已初始化,直接返回,避免了昂贵的锁开销。 - Lock(加锁):若未初始化,则获取全局锁,进入临界区。
- Second Check(有锁检查):再次检查标志位。这是为了防止在“第一步检查”和“第二步加锁”的微小时间窗口内,已有其他线程抢先完成了初始化。
- Init(执行初始化):只有通过了这两道防线,才会真正调用底层特定平台的
netpollinit()。
这种严谨的逻辑闭环,保证了 Netpoller 在高并发启动时的绝对线程安全。
继续深入,代码将把我们带入汇编语言的领域。虽然我们不需要逐行深究汇编指令,但这里有两个设计哲学值得我们特别关注:
- 多路复用器的“自动驾驶”与屏蔽差异
Go 语言遵循“编写一次,到处编译”的哲学。在源码层面,Go 通过 Build Tags(构建标签)为不同的操作系统提供了不同的实现文件(例如 Linux 下的
netpoll_epoll.go,macOS 下的netpoll_kqueue.go)。 上层逻辑无需关心底层是epoll、kqueue还是IOCP,Go Runtime 会根据编译目标平台自动链接对应的底层操作集。通过这种屏蔽差异的封装,用户层感受到的是统一的异步 I/O 体验,而netpoll这个名称本身,就是对所有这些多路复用技术的一个高度抽象。 - 直面内核:Syscall6 与寄存器操作
在 Go 1.19 及后续版本中,特别是在
internal/syscall/unix路径下,Go 暴露了如Syscall6这样的底层接口。这样一个通用的汇编调用接口展示了go区别与java,php的自实现特点。 - 寄存器级操作:这实际上是 Go 语言从“用户态”跃迁至“内核态”的跳板。正如之前提供的视频中讨论的([Core Dumped 的这期科普视频](https://www.youtube.com/watch?v=7ge7u5VUSbE [00:46]))中所讨论的,系统调用(System Call)本质上是将参数装入特定的 CPU 寄存器(如 RAX, RDI, RSI 等),然后触发软中断(Trap)请求操作系统内核介入。
- 零成本封装:Go 语言在这里并没有依赖庞大的 C 标准库(libc),而是直接通过汇编代码封装了这些操作码(OpCode)。这不仅减小了二进制体积,更重要的是帮助开发者规避了复杂的寄存器管理,提供了一个既接近硬件极限速度、又具备类型安全保障的系统调用接口。
-
核心接入点:poll_runtime_pollOpen 在 poll_runtime_pollOpen 中,我们可以看到
netpoll是如何将底层的网络描述符(FD)纳入到 Runtime 的监管之下的。这个函数起到了承上启下的作用:// 伪代码逻辑概览 func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) { // 1. 从缓存池中获取或分配一个新的 pollDesc pd := pollcache.alloc() // 2. 初始化 pollDesc,这一步非常关键 // 必须确保此时没有其他 goroutine 对该 pd 进行读写或等待 // 这里会设置 pd.fd = fd,并生成新的序列号 fdseq lock(&pd.lock) if pd.wg != 0 && pd.wg != pdReady { throw("runtime: blocked write on free polldesc") } ... unlock(&pd.lock) // 3. 调用特定平台的实现(如 epoll/kqueue),将 fd 注册到内核 errno := netpollopen(fd, pd) return pd, errno }
-
状态检查与版本控制:这里的初始化不仅仅是赋值。
pollDesc是会被复用的(后文会详细讲解缓存机制),因此必须确保拿到的pd是“干净”的,且没有残留的 Goroutine 在等待它。 -
竞争与版本号:为了防止竞争,这里使用了加锁操作。更重要的是,这里引入了
fdseq(文件描述符序列号)。这是一个极其重要的设计,用于解决 ABA 问题:防止一个 Socket 关闭后,新的 Socket 复用了同一个 FD 和同一个pollDesc,导致旧的事件错误地唤醒了新的连接。
继续深入 netpollopen(以 Linux Epoll 为例),这里有两处极具 Go 特色的底层优化:
1.Edge Triggered (ET) 模式:
- 代码中设置了
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET。 - Go 毫不犹豫地选择了
EPOLLET(边缘触发),这与我们在 C 语言网络编程中的高阶实践一致。 - 原因:ET 模式仅在状态变化时通知一次,减少了
epoll_wait返回的次数,极大地降低了系统调用的频率,是构建高性能网络库的基石。
Tagged Pointer(指针压缩技术):
-
这是一个非常精妙的技巧。在
epoll_ctl的epoll_data联合体中,我们只有一个 64 位的空间来存储上下文。如果我们只存pd指针,就无法携带fdseq版本号;如果我们只存 FD,就无法快速找到pd对象。 Go 的解决方案是将 指针地址 和 版本号 压缩进同一个uintptr中: -
低位利用:由于 64 位机器上的内存对齐(通常是 8 字节对齐),指针的最后 3 位()始终为 0。
-
高位利用:虽然指针是 64 位的,但现代 CPU(如 AMD64)通常只使用低 48 位进行寻址(虚拟地址空间限制)。
-
打包逻辑:Go 利用这些“无用”的位(高位和通过位运算挤出的空间凑齐 10 位容纳 1023 个版本号),将
fdseq嵌入其中。 -
校验:在使用时,重新拆解这个 Tagged Pointer,既能还原出
pd的内存地址,又能取出版本号与当前pd的版本号比对,从而完美检测出数据是否过期或泄露。
最后,通过 syscall.EpollCtl 完成向内核的注册。
回到 poll_runtime_pollOpen 的开头,我们来探讨 pollDesc 是如何被创建和管理的。
- 批量申请与链表缓存:
Go Runtime 极度厌恶频繁的小对象内存分配。因此,
pollDesc采用了一个全局的pollCache链表进行管理:// 伪代码 lock(&c.lock) if c.first == nil { // 缓存为空,调用 persistentalloc 一次性申请一批(例如 4KB 大小) // 并将它们串成链表 } pd := c.first c.first = pd.link unlock(&c.lock)
获取和归还(Free)仅仅是简单的链表指针操作,开销几乎可以忽略不计。
- PersistentAlloc 与 GC 隔离:
这里使用
persistentalloc申请内存,而非普通的new。 - 非 GC 内存:这部分内存被标记为“持久”的,Go 的垃圾回收器(GC)不会扫描这块内存区域。
- 性能考量:
pollDesc是 Runtime 内部使用的结构体,不包含指向 Go 堆对象的指针(除了弱引用),且生命周期由 Runtime 手动管理。将其排除在 GC 扫描之外,极大地减少了 GC 的工作量(Mark 阶段的开销),这是 Go 能支撑百万级并发连接的隐形功臣之一。 - 地址稳定性:这也保证了
pollDesc的物理内存地址不会移动,这对于将其地址传递给操作系统内核(如 Epoll)是至关重要的。
1. pollCache:链表式内存池:
-
首先,我们回顾 pollcache 结构体。它在实际的网络业务逻辑中并不直接参与数据传输,而是扮演着 Memory Pool(内存池) 或 Free List(空闲链表) 的角色。
-
结构定义:它本质上是一个受锁保护的单向链表头。
type pollCache struct { lock mutex first *pollDesc // 指向空闲链表的第一个节点 }
-
架构意义:
-
复用机制:当一个网络连接关闭时,其对应的
pollDesc不会被立即释放(free)回操作系统,而是被回收到这个链表中。 -
性能优化:在处理高频短连接场景时,这种设计避免了频繁调用
persistentallo和 GC 压力。获取一个pollDesc只是简单的指针操作,耗时极低。
2. pollDesc:Netpoll 的心脏
-
pollDesc (Polling Descriptor) 是整个
netpoll体系中最为复杂的结构体。它是 Go Runtime 层面对应底层网络文件描述符的“影子对象”。 -
为了清晰地理解它的职责,我们可以将其字段划分为四大功能模块:
1. 身份与链路 (Identity & Linkage)
link *pollDesc:链表指针。当该对象处于pollcache中时,它指向下一个空闲节点;当处于活跃状态时,该字段通常为 nil。fd uintptr:核心身份标识。这是操作系统分配的原始 Socket 文件描述符(File Descriptor)。正是这个值被注册到了 epoll/kqueue 中。- 注:在之前的
netpollopen中,我们将pollDesc的指针地址(经过 Tagged Pointer 封装)写入了epoll_event.data,实现了内核事件到 Go Runtime 对象的反向映射。
2. 数据保护 (Concurrency Control)
lock mutex:互斥锁。用于保护pollDesc自身状态的原子性,防止多个 Goroutine 同时操作同一个 FD(例如并发读写或并发关闭)。atomicInfo atomic.Uint32:原子状态位。用于快速判断当前 FD 的状态(如是否已关闭、是否被中断),实现无锁的快速检查。
3. 调度器耦合 (Scheduler Integration) —— 最关键的设计 这是 Go 实现“同步语义,异步底层”的核心所在。
pollDesc包含两个关键字段:-
rg uintptr(Read Group / Read G) -
wg uintptr(Write Group / Write G) -
这两个字段是一个轻量级的状态机,它们的值不仅仅是简单的 0 或 1,而是包含以下三种状态:
-
0(pdNil):空闲状态。当前没有 Goroutine 在等待该 FD 的读/写事件。 -
pdReady(1):就绪状态。表示 Epoll 已经通知 Runtime 该 FD 可读或可写。此时 Goroutine 调用 Read/Write 不会阻塞,而是直接进行系统调用。 -
pdWait(2):等待状态。表示 Goroutine 准备挂起。 -
> 2(G 指针):这是真正的魔法。当一个 Goroutine 因为 I/O 未就绪而需要阻塞时,它会将 自己的地址(*g) 写入这里。当 Epoll 唤醒时,Netpoll 会读取这个地址,直接将对应的 Goroutine 扔回调度器的运行队列(Run Queue)。
4. 超时控制 (Deadline Management)
rt timer(Read Timer) /wt timer(Write Timer):Go 语言层面的定时器。seq uintptr(Sequence):全局唯一的序列号。- 机制:当我们调用
SetReadDeadline时,实际上是向 Go 的堆定时器中注册了一个事件。如果定时器触发时 I/O 仍未完成,Runtime 会通过比对seq来确保这是当前操作的超时,然后强制唤醒阻塞在rg/wg上的 Goroutine,并返回i/o timeout错误。
总结:
pollDesc 巧妙地将底层的 IO 资源(FD)、中间层的 IO 状态(rg/wg 状态机)以及上层的 调度实体(Goroutine 地址)通过一个结构体紧密耦合在一起。这使得 Go 能够在内核通知事件到来时,以 O(1) 的复杂度瞬间找到并唤醒正确的 Goroutine。
在上两节中,我们深入探讨了 listen 的底层系统调用实现。在正式进入 accept 之前,我们先自下而上地回顾一下这个函数的返回值封装,理清 Go 是如何将底层资源暴露给用户层的。
首先,对接到我们 C 语言视角的部分是 socket 文件描述符。这个原始的文件描述符经由上层 listenTCPProto 封装,最终成为了 TCPlistener 结构体。这个结构体不仅仅是文件描述符的容器,它还内嵌了 listenConfig 结构体。listenConfig 包含了 control 钩子函数、KeepAlive 探测周期等配置,它的意义在于将操作系统底层的网络参数设置向用户层敞开,允许开发者在 Go 语言层面通过配置字段来对底层的 socket 行为做进一步的限制和明确。
最终,通过结构体定义和对 tcplistener 的方法封装,Go 将底层的 listen 系统调用和上层的配置结构体紧密“偶联”在一起。这种关系在我们调用 net.Listen 时,通过传入的 network 和 address 字段就已经决定好了。
值得注意的是,listen 这个接口主要用于处理流式和面向连接的协议(如 tcp, ssl 等);与其平行的功能接口还有 PacketConn,用于处理数据包协议(如 udp, dns 等)。这种设计完美体现了 Go 语言面向接口编程的思想,将不同协议的实现细节隐藏在统一的接口之下,实现了代码的模块化与组件化。
现在我们理解了 Listener 的构建过程。我们在业务代码中调用的 Accept 方法,本质上是通过接口与 func (ln *TCPListener) accept() 关联起来的。接下来我们将从 accept 的上层入口出发,自上而下地剖析其底层实现原理。
-
首先,代码逻辑会来到
internal/poll包下的 accept 函数。这个函数是网络轮询器(Netpoller)与 socket 交互的关键枢纽,主要完成了以下四个核心功能:- 调用系统调用:封装并执行底层的
accept系统调用。 - 对象包装:将返回的新文件描述符包装成
netFD(Network File Descriptor) 对象。 - 注册轮询:将新的
netFD注册到 epoll(或对应平台的 IO 多路复用器)中,以便监听后续的读写事件。 - 完善信息:填充对端(Remote)和本地(Local)的地址信息。
- 调用系统调用:封装并执行底层的
-
我们重点关注
internal/poll/fd_unix.go中的核心逻辑 accept:func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) { // ... 准备工作与加锁 ... // 循环尝试 Accept for { // 执行底层系统调用 accept s, rsa, errcall, err := accept(fd.Sysfd) // 1. 如果成功,直接返回 if err == nil { return s, rsa, "", err } // 2. 处理系统调用返回的错误 switch err { case syscall.EINTR: // 信号中断,重试 continue case syscall.EAGAIN: // 核心逻辑:EAGAIN 表示当前 socket 接收缓冲区为空(无新连接) // 如果 fd 是可轮询的 (pollable),则挂起当前 Goroutine 等待读事件 if fd.pd.pollable() { if err = fd.pd.waitRead(fd.isFile); err == nil { continue // 被唤醒后,继续循环尝试 accept } } case syscall.ECONNABORTED: // 这种错误通常意味着连接在建立过程中被对端复位,忽略并重试 continue } return -1, nil, errcall, err } }
-
这里的非阻塞 I/O 思路与我们在 C 语言中 netpoll 实现一节的思路完全一致:当
accept返回EAGAIN时,不让线程睡眠,而是让出 CPU。 -
关键点:Go 的强大之处在于,当遇到
EAGAIN时,它调用fd.pd.waitRead将当前 Goroutine 挂起,而不是阻塞操作系统线程。
-
在系统调用
accept成功返回拿到文件描述符后,Go 并没有直接使用这个裸露的 int 类型句柄,而是通过 newFD 函数将其包装成了一个功能丰富的对象。 -
这个函数不仅仅是简单的内存分配,它确定了 Go 运行时如何看待这个网络连接:
-
poll.FD:这是核心中的核心,它充当了 Go 语言 IO 层(用户代码)和 Runtime 层(调度器)之间的桥梁。
-
Sysfd:保存了底层的 socket 句柄,一切操作最终都落实在这个整数上。
-
IsStream:标记是否为流式套接字。
-
如果是 TCP,此值为
true。 -
如果是 UDP,此值为
false。 -
ZeroReadIsEOF:这是结束判定的关键规则。
-
正如我们在 udp 章节中提到的,UDP 允许发送 0 字节的数据包,这在 UDP 中不代表连接结束。
-
而在 TCP 中,
read返回 0 字节通常意味着对端发送了 FIN 包(EOF),连接需要关闭。 -
newFD会根据IsStream的值自动设置这个字段,确保上层业务逻辑能正确处理“读到 0 字节”的含义。 -
并发安全:
newFD初始化的对象内部维护了读写锁(fdMutex),这是 Go 能够让多个 Goroutine 安全地对同一个 socket 进行并发操作(尽管通常不建议这样做)的底层保障。
-
包装完成后,紧接着就是“激活”这个连接,这一步由 init 完成。
-
这个函数的逻辑与我们之前在 listen 中看到的思路完全一致,可谓是殊途同归:
-
Listen 阶段将监听 socket (
listener) 注册到 epoll 中,为了监听“新连接到来”的事件。 -
Accept 阶段将新建立的连接 socket (
conn) 注册到 epoll 中,为了监听“数据可读/可写”的事件。 -
底层最终都调用了
poll.runtime_pollOpen,将Sysfd添加到 epoll 实例的红黑树中。一旦这一步完成,这个连接就正式进入了 Go 的网络轮询器(Netpoller)的管理范围,为后续的异步 I/O 奠定了基础。
-
进一步深入
waitRead函数,我们最终通过//go:linkname链接机制,从internal/poll包跨越到了runtime包的 poll_runtime_pollWait。 -
注意,此时我们的业务结构体已经从
poll.FD转化为了 runtime 内部的polldesc(poll descriptor),这种转化依赖于结构体的映射关系。func poll_runtime_pollWait(pd *pollDesc, mode int) int { // 1. 检查连接是否已经出错或关闭 errcode := netpollcheckerr(pd, int32(mode)) if errcode != pollNoError { return errcode } // 2. 循环等待,直到 netpollblock 返回 true (表示 IO 就绪) for !netpollblock(pd, int32(mode), false) { // 被唤醒后再次检查错误 errcode = netpollcheckerr(pd, int32(mode)) if errcode != pollNoError { return errcode } // 如果是因为超时被唤醒,但还没来得及运行超时就被重置了, // 则假装没发生,继续重试。 } return pollNoError }
-
在真正挂起之前,runtime 先通过 netpollcheckerr 检查是否有超时或关闭错误。
-
这里的
for循环逻辑是为了处理一些边缘情况(例如超时触发后又被重置),防止并发修改导致状态不一致,确保每一次挂起都是有效的。
-
接下来我们深入 netpollbloack,这是 Goroutine 挂起的决策中心。
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool { // 根据 mode 选择是操作读通道(rg) 还是 写通道(wg) gpp := &pd.rg if mode == 'w' { gpp = &pd.wg } for { // CAS 操作:原子性地判断状态 // Case 1: 如果状态已经是 pdReady (IO 就绪),则将状态重置为 pdNil 并返回 true // 表示不需要等待,直接去读/写数据 if gpp.CompareAndSwap(pdReady, pdNil) { return true } // Case 2: 如果状态是 pdNil (初始状态),则将其设置为 pdWait (等待中) // 只有设置成功,才能跳出循环去执行 gopark 挂起 if gpp.CompareAndSwap(pdNil, pdWait) { break } // Case 3: 如果既不是 Ready 也不是 Nil,说明出现了并发等待,抛出异常 if v := gpp.Load(); v != pdReady && v != pdNil { throw("runtime: double wait") } } // 执行挂起操作 // 传入 netpollblockcommit 作为回调,它会在 g0 栈上执行 if waitio || netpollcheckerr(pd, mode) == pollNoError { gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5) } // 被唤醒后的逻辑 old := gpp.Swap(pdNil) if old > pdWait { throw("runtime: corrupted polldesc") } return old == pdReady }
-
状态机管理:
pd.rg和wg是原子操作的 uintptr,它们在前面 polldesc 一节中提到,默认值为pdNil(0)。 -
CAS (CompareAndSwap):这里利用 CAS 实现无锁状态流转。将“值检测”和“值交换”融为一体,确保了多线程下的安全性。
-
挂起与唤醒:
gopark是分水岭。执行gopark前,当前 Goroutine 正在运行;gopark返回后,说明 Goroutine 已经被 epoll 事件唤醒,此时检查返回值是否为pdReady,确认是由数据包唤醒而非超时。
-
最后,我们解析最底层的调度器接口 gopark。这是 Goroutine 让出 CPU 控制权的关键。
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { // ... mp := acquirem() // 1. 禁止当前 M 被抢占 gp := mp.curg // ... 状态检查 ... // 2. 保存当前 Goroutine 的现场 (SP, PC 等) // 3. 切换到 g0 栈执行 park_m 函数 mcall(fastrand) // 注意:mcall 会调用 park_m,代码逻辑跳转到 park_m // ... }
-
acquirem/release:
acquirem本质是增加当前 M (系统线程) 的锁计数,防止在保存现场这种敏感操作期间,M 被调度器抢占或被 GC 扫描干扰。 -
mcall 与 g0:Goroutine 的栈是动态伸缩的,而挂起操作涉及到栈的切换。为了安全,必须切换到 g0 栈(系统栈,大小固定且较大)来执行调度逻辑。
-
回调函数:这里的
unlockf就是我们在netpollblock中传入的 netpollblockcommit。 -
在 g0 栈中,系统会执行 park_m 函数:
func park_m(gp *g) { // ... // 1. 修改 Goroutine 状态:从 _Grunning 变为 _Gwaiting casgstatus(gp, _Grunning, _Gwaiting) // 2. 解除 M 和 G 的绑定 dropg() // 3. 执行回调函数 netpollblockcommit if fn := mp.waitunlockf; fn != nil { ok := fn(gp, mp.waitlock) // 在这里,gp 的地址被写入了 pollDesc 中 // ... } // 4. M 寻找下一个可运行的 Goroutine schedule() }
-
casgstatus:原子地将协程状态从运行中(Running)标记为等待中(Waiting)。
-
dropg:彻底解绑
mp.curg = nil和gp.m = nil。此时 G 已经“睡”在堆上了,而 M 获得了自由。 -
关键回调:执行 netpollblockcommit,通过
atomic.Store(gpp, gp)将当前 Goroutine 的内存地址填入pd.rg中。这一步至关重要,它相当于告诉 netpoller:“当这个 socket 有数据来时,请唤醒地址为gp的这个协程”。 -
schedule:M 并没有休息,它立即执行
schedule()去全局队列或本地队列寻找下一个待执行的 G。这就是 Go 高并发的核心秘密——IO 阻塞的是 Goroutine,而不是系统线程。 你的行文逻辑已经非常合适。
至此,我们完整解构了 Accept 从用户层 API 到 Runtime 调度器的全过程。这一复杂的调用链完美诠释了 Go 语言同步的代码逻辑,异步的底层实现的核心设计哲学:
- 表象与本质的统一:
- 对开发者:
Accept表现为标准的阻塞式 I/O。代码线性执行,逻辑清晰,符合人类直觉,不需要像 C 语言 epoll 那样编写复杂的回调或状态机。 - 对操作系统:
Accept实际上是非阻塞 I/O。底层通过EAGAIN错误码和epoll机制,确保了系统线程永远不会因为等待网络数据而阻塞。
- M 与 G 的接力:
整个流程的关键点在于
gopark和schedule的配合。当 I/O 未就绪时:
- Goroutine (G) 选择“让出”:它保存现场,进入
_Gwaiting状态,乖乖在堆上等待数据到来。 - 系统线程 (M) 选择“复用”:它通过切换到
g0栈,迅速摆脱了当前 G 的纠缠,立即执行schedule()寻找下一个需要 CPU 的 G。
- 高性能的秘密: 这就解释了为什么 Go 服务端可以用少量的系统线程支撑数万计的并发连接——因为所有的“等待”成本都由极度廉价的 Goroutine 承担了,而昂贵的系统线程(M)始终处于高负载的有效计算状态,从未真正休息。 这正是 Go 网络模型区别于传统多线程模型的最大护城河。