网络编程在做什么:客户端与服务器
网络编程的本质很朴素:两个程序各自在自己的机器上运行,通过网线把字节送来送去。一边叫服务器(先守在一个端口上等别人来连),一边叫客户端(主动发起连接去连服务器)。你平时打开网页,浏览器就是客户端,远端那台机器就是服务器。
所有这类通信都建立在一个操作系统抽象之上——socket(套接字)。你可以把它理解成一个"带编号的文件句柄":往里写字节就发出去了,从里读字节就收到对端的数据。收发网络和读写文件在 C++ 里的味道惊人地像。
socket 是操作系统替你管理的通信端点。网络编程那套 bind、listen、accept,其实就是围绕这个端点的几步仪式。
- socket()创建一个通信端点,返回一个文件描述符。
- bind/listen/accept服务器侧三步走,开始等待连接。
- connect客户端主动连到服务器的地址与端口。
- 阻塞与异步默认 recv 会卡住等数据;异步 I/O 让单线程处理大量连接。
socket:操作系统给的通信端点
第一步永远是向操作系统要一个 socket:调用 socket(),指定用 IPv4 地址族、流式(TCP)协议,它返回一个整数描述符。后面所有操作都拿这个描述符去谈。
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
三个参数记住大意即可:AF_INET 表示用 IPv4,SOCK_STREAM 表示要一条可靠的字节流(也就是 TCP),而不是无连接的 UDP。这个描述符和你读文件时拿到的句柄地位相当,用完一样要 close。
一次连接:bind、listen、accept、connect
服务器这边要做三步:先 bind 把 socket 绑到本机的某个端口,再 listen 宣布"开始排队等连接",最后 accept 从队列里取出一个真正到来的连接。客户端则只需 connect 到服务器的地址和端口。
// 服务器侧
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080); // 端口 8080
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
listen(fd, 10); // 最多排 10 个待连接
int conn = accept(fd, NULL, NULL); // 阻塞,直到有人来连
accept 返回的是一个新的 socket,专门用来和这一个客户端对话;原来那个监听 socket 继续等下一个连接。这就是"一个监听、多个会话"的模型雏形。
收发数据:send 与 recv
连接建立后,收发就是两个函数:send 把缓冲里的字节发给对端,recv 从对端收字节到缓冲里。
char buf[1024];
int n = recv(conn, buf, sizeof(buf) - 1, 0); // 读到 n 字节
buf[n] = '\0';
send(conn, "hello client\n", 13, 0);
close(conn);
一个常被忽略的事实:TCP 是字节流,没有消息边界。你 send 两次,对端可能一次 recv 全收到,也可能分好几次。所以真实的协议层要自己约定"一个消息到哪算结束"——比如按固定长度、或在末尾加一个结束标记。
阻塞的问题:为什么需要异步 I/O
上面那版代码有个致命问题:accept 和 recv 默认是阻塞的——没连接时 accept 卡住,没数据时 recv 卡住。一个线程一次只能伺候一个客户端。来了一千个连接怎么办?老式办法是"每来一个连接就开一个线程",但线程很重,几千个线程会把内存和上下文切换吃光。
- 阻塞模型:连接一多,线程数爆炸,系统扛不住。
- 多线程模型:能并行,但每连接一线程开销大、难管理。
- 异步 I/O:让一个线程盯着很多连接,哪个有数据就处理哪个,不用为每个连接养一个线程。
现代高并发服务器几乎都走异步路线——这正是 Redis、Nginx 能单机扛住数万连接的底层原因。
epoll 直觉:单线程处理很多连接
Linux 上这套机制叫 epoll。你把成千上万个连接 socket 都"登记"给 epoll,然后问它一句:现在哪些连接有数据就绪了?epoll 只把就绪的那几个返回给你,你逐个处理即可。一个线程就这样轮着伺候了所有连接,而不是死等某一个。
// 伪代码:epoll 的核心循环
int ep = epoll_create1(0);
epoll_ctl(ep, EPOLL_CTL_ADD, conn, &ev); // 登记连接
while (running) {
int n = epoll_wait(ep, events, MAX, -1); // 等到有连接就绪
for (int i = 0; i < n; i++) {
handle(events[i]); // 只处理就绪的
}
}
你不必一开始就手写 epoll——现实中直接用成熟库(如 asio、libuv)即可。但理解了"登记一堆连接、只处理就绪的"这个思路,你就理解了所有现代异步服务器的骨架。
实战:一个最小的 TCP 服务器
把服务器侧几步串起来:建 socket、绑端口、开始监听、接受一个连接、回一句话、关掉。用 telnet 或 nc 连上去就能看到效果。
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in a = {0};
a.sin_family = AF_INET;
a.sin_port = htons(8080);
a.sin_addr.s_addr = htonl(INADDR_ANY);
bind(fd, (struct sockaddr*)&a, sizeof(a));
listen(fd, 10);
int conn = accept(fd, NULL, NULL);
char buf[1024];
int n = recv(conn, buf, sizeof(buf) - 1, 0);
buf[n] = '\0';
send(conn, "got it\n", 7, 0);
close(conn);
close(fd);
这就是一个能跑的最小服务器雏形:它一次只接待一个连接,接待完就结束。从这里出发,加上 epoll、加上多连接循环、加上协议解析,才一步步长成真实的后端服务。
网络编程检查清单
把 socket 编程记成一条链:socket 建端点 → bind/listen 监听 → accept/connect 连起来 → send/recv 收发 → close 释放。提交代码前对照自查:
- 每个 socket、每个 accept 出来的连接,用完都 close 了吗?
- recv 读到的是"字节流",你的协议有没有自己处理消息边界?
- 有没有默认阻塞导致一个线程卡在一个连接上?高并发场景是否考虑了异步 I/O?
- 端口号是否正确、是否与别的进程冲突?
- 错误返回值(小于 0)都检查并 perror 了吗?
网络编程的难,不在写出能连上的第一版,而在处理"对端随时断开、数据随时迟到、连接数随时暴涨"这些边界。先把最小服务器跑通,再逐个加固。
下一篇我们离开网络,回到 C# 的语法深处:委托、事件与 LINQ 如何把数据处理写得又短又顺。