第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 服务器端流程

服务器端的套路很固定,我总结为四步走:

  1. socket():创建socket
  2. bind():绑定IP和端口
  3. listen():开始监听
  4. 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 客户端流程

客户端更简单,两步走:

  1. socket():创建socket
  2. 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。你可以继续干别的事,过一会儿再来检查。

设置非阻塞有两种方式:

  1. 创建时指定:socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)
  2. 运行时设置: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 知识体系图

下面这张图总结了本章的核心知识点和它们之间的关系:

网络编程核心知识体系 socket编程 TCP客户端/服务器 select多路复用 非阻塞IO socket() bind()/listen() accept() connect() fd_set位图 FD_SET/FD_CLR FD_ISSET检查 SOCK_NONBLOCK fcntl()设置 EAGAIN处理 三者结合:非阻塞socket + select轮询 = 高性能服务器

19.6 实战经验总结

最后,分享几个我在项目中踩过的坑:

  • 字节序问题:网络字节序是大端,x86是小端。bind端口时一定要用htons()转换。我见过有人直接写8080,结果bind到了别的端口上。
  • 信号干扰:read/write被信号中断时会返回-1,errno是EINTR。这时候应该重试,而不是直接报错。
  • select的timeout:如果设为NULL,会一直阻塞。设为0,则立即返回(轮询模式)。我一般设个100ms的超时,既不会太忙,也不会太慢。
  • 非阻塞connect:connect本身也可以是非阻塞的。调用后立即返回,然后用select检查是否连接成功。这在写高并发客户端时很有用。

网络编程这东西,光看是学不会的。你得自己动手写一个echo服务器,再写一个客户端连上去,看着数据一来一回,才能真正理解。嗯,今天就到这里,去写代码吧。


公众号:蓝海资料掘金营,微信deep3321