第19章:网络编程基础——socket创建、TCP客户端/服务器、select多路复用、非阻塞IO
网络编程,说白了就是让两台机器能互相说话。我刚开始学的时候,觉得这东西特别玄乎——数据怎么就从我这台电脑跑到别人那去了?后来写多了才发现,核心就那么几个概念:socket、bind、listen、accept、connect。今天咱们就把这些讲透。
19.1 socket是什么?
socket,中文叫“套接字”,你可以把它想象成一根网线的插头。一端插在你的程序里,另一端插在远程机器上。数据从这头进去,从那头出来。
在Linux里,socket也是一个文件描述符。嗯,你没看错——跟打开文件返回的那个int是一回事。所以你可以用read()、write()来读写socket,也可以用close()关掉它。
核心函数:socket()
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
- domain:地址族,AF_INET(IPv4)或AF_INET6(IPv6)
- type:SOCK_STREAM(TCP)或SOCK_DGRAM(UDP)
- protocol:一般填0,让系统自动选
返回一个int,就是socket描述符。如果返回-1,说明创建失败了。我见过不少新手忘了检查返回值,结果后面各种段错误。
19.2 TCP客户端/服务器模型
TCP是面向连接的协议。什么意思?就是通信之前先“握手”,确认双方都在线,然后再传数据。就像你打电话,先拨号,对方接了,才开始说话。
19.2.1 服务器端流程
服务器端的套路很固定,我总结为四步走:
- socket():创建socket
- bind():绑定IP和端口
- listen():开始监听
- accept():接受客户端连接
避坑指南:bind之前,最好设置一下SO_REUSEADDR选项。我曾经在调试时频繁重启服务器,结果bind总是报“Address already in use”。加上这个选项,就能重用TIME_WAIT状态的端口。
// 服务器端核心代码
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
addr.sin_port = htons(8080); // 端口号,网络字节序
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5); // 最大等待队列长度
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
19.2.2 客户端流程
客户端更简单,两步走:
- socket():创建socket
- connect():连接服务器
// 客户端核心代码
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr));
连接成功后,两边就可以用read()和write()互相发数据了。注意,TCP是流式协议,没有消息边界。你发两次write("hello")和write("world"),对方可能一次read就收到"helloworld"。所以应用层需要自己定义消息格式,比如加个长度头。
19.3 select多路复用
上面那个模型有个问题:一个服务器只能服务一个客户端。如果accept之后,你开始read,那其他客户端就只能排队等着。这显然不行。
解决方案之一就是select。它让你可以同时监视多个socket,哪个有数据来了,就去处理哪个。
select()函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
fd_set是一个位图,每个bit对应一个文件描述符。用FD_SET、FD_CLR、FD_ISSET这几个宏来操作它。
我个人习惯这样用:
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds); // 把服务器socket加进去
int max_fd = server_fd;
while (1) {
fd_set tmp_fds = read_fds; // select会修改fd_set,所以每次要重新赋值
int ret = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
if (ret < 0) {
perror("select error");
break;
}
// 检查服务器socket是否有新连接
if (FD_ISSET(server_fd, &tmp_fds)) {
int client_fd = accept(server_fd, NULL, NULL);
FD_SET(client_fd, &read_fds);
if (client_fd > max_fd) max_fd = client_fd;
}
// 检查各个客户端socket是否有数据
for (int i = 0; i <= max_fd; i++) {
if (i == server_fd) continue;
if (FD_ISSET(i, &tmp_fds)) {
char buf[1024];
int n = read(i, buf, sizeof(buf));
if (n <= 0) {
close(i);
FD_CLR(i, &read_fds);
} else {
// 处理数据...
}
}
}
}
注意:select有最大文件描述符数量的限制,通常是1024。如果你要处理成千上万的连接,建议用epoll(Linux)或kqueue(BSD)。
19.4 非阻塞IO
默认情况下,socket是阻塞的。比如你调用read(),如果没有数据,线程就会卡在那里,直到有数据到来。这在单线程服务器里是致命的——一个客户端卡住了,其他客户端全得等着。
非阻塞IO就是让这些操作立即返回。没数据?返回-1,errno设为EAGAIN或EWOULDBLOCK。你可以继续干别的事,过一会儿再来检查。
设置非阻塞有两种方式:
- 创建时指定:socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)
- 运行时设置:fcntl(sock, F_SETFL, fcntl(sock, F_GETFL) | O_NONBLOCK)
我更喜欢第二种,因为灵活。比如服务器socket可以阻塞,但客户端socket设成非阻塞。
// 设置非阻塞
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
// 非阻塞read
char buf[1024];
int n = read(sock, buf, sizeof(buf));
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// 暂时没数据,过会儿再试
} else if (n == 0) {
// 对方关闭连接
} else {
// 正常处理数据
}
19.5 知识体系图
下面这张图总结了本章的核心知识点和它们之间的关系:
19.6 实战经验总结
最后,分享几个我在项目中踩过的坑:
- 字节序问题:网络字节序是大端,x86是小端。bind端口时一定要用htons()转换。我见过有人直接写8080,结果bind到了别的端口上。
- 信号干扰:read/write被信号中断时会返回-1,errno是EINTR。这时候应该重试,而不是直接报错。
- select的timeout:如果设为NULL,会一直阻塞。设为0,则立即返回(轮询模式)。我一般设个100ms的超时,既不会太忙,也不会太慢。
- 非阻塞connect:connect本身也可以是非阻塞的。调用后立即返回,然后用select检查是否连接成功。这在写高并发客户端时很有用。
网络编程这东西,光看是学不会的。你得自己动手写一个echo服务器,再写一个客户端连上去,看着数据一来一回,才能真正理解。嗯,今天就到这里,去写代码吧。
公众号:蓝海资料掘金营,微信deep3321