一、TCP 基础通信回顾

1.1 TCP 编程流程

TCP 客户端(主动角色)          TCP 服务器(被动角色)
1. socket()                   1. socket()
2. connect()                  2. bind()
                              3. listen()
                              4. accept()
   收发数据                    收发数据
   close()                     close()

1.2 字节序转换函数

网络通信中,不同主机的字节序(大端/小端)可能不同,因此需要转换:

#include <stdio.h>
#include <arpa/inet.h>

int main(int argc, const char *argv[])
{
    unsigned short a = 0x1234;

    // htons: host to network short
    // 将主机字节序的 short 转为网络字节序
    int ret = htons(a);
    printf("ret = %#x\n", ret);  // 大端机器输出 0x1234,小端机器输出 0x3412
    return 0;
}

常见转换函数:

函数说明
htons()host to network short(16位,端口号)
htonl()host to network long(32位,IP地址)
ntohs()network to host short
ntohl()network to host long
inet_addr()字符串IP → 网络字节序整数
inet_ntoa()网络字节序整数 → 字符串IP

1.3 基础 TCP 服务器(无通信)

最简单的服务器,只接受连接、打印客户端信息,不做通信:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    // 第1步:创建 TCP socket
    // AF_INET     —— IPv4
    // SOCK_STREAM —— 流式套接字(TCP专用)
    // 0           —— 自动选择协议(TCP)
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 准备服务器地址
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    // 第2步:绑定地址
    if (bind(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("bind fail");
        return -1;
    }

    // 第3步:设置监听
    // 参数 5 表示等待连接的队列最大长度
    if (listen(fd, 5) < 0)
    {
        perror("listen fail");
        return -1;
    }

    // 第4步:接受连接
    struct sockaddr_in cliaddr;
    socklen_t addrlen = sizeof(cliaddr);
    // accept 阻塞等待客户端连接,成功返回新的 socket(已连接)
    int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
    if (connfd < 0)
    {
        perror("accept fail");
        return -1;
    }

    // 打印连接的客户端信息
    printf("accept success\n");
    printf("-------------------\n");
    printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
    printf("port        = %d\n", ntohs(cliaddr.sin_port));
    printf("-------------------\n");

    return 0;
}

1.4 基础 TCP 客户端

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, const char *argv[])
{
    // 第1步:创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 准备服务器地址(要连接谁)
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    // 第2步:连接服务器
    // 连接成功后,fd 就变成了"已连接"的 socket,可以直接 read/write
    if (connect(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("connect fail");
        return -1;
    }

    // 发送一条简单消息
    write(fd, "hello tcp!\n", 11);
    return 0;
}

小技巧:文件描述符默认遵循从小到大分配,所以先创建的是 listenfd(如值为 3),accept 返回的 connfd 一般更大(如值为 4)。


1.5 简单回发服务器与客户端

服务器(回发)
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

int main(int argc, const char *argv[])
{
    // 创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 绑定地址
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (bind(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("bind fail");
        return -1;
    }

    // 监听
    if (listen(fd, 5) < 0)
    {
        perror("listen fail");
        return -1;
    }

    // 接受连接
    struct sockaddr_in cliaddr;
    socklen_t addrlen = sizeof(cliaddr);
    int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
    if (connfd < 0)
    {
        perror("accept fail");
        return -1;
    }
    printf("accept success\n");
    printf("-------------------\n");
    printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
    printf("port        = %d\n", ntohs(cliaddr.sin_port));
    printf("-------------------\n");

    // 循环通信:接收客户端消息 → 拼接 "server +" 前缀 → 回发
    char buf[1024] = {0};
    while (1)
    {
        // 从 connfd 读取客户端发来的数据
        read(connfd, buf, sizeof(buf));

        if (strncmp(buf, "quit", 4) == 0)
            break;

        // 拼接 "server +" 前缀
        char sbuf[1024] = {0};
        sprintf(sbuf, "server + %s\n", buf);

        // 回发给客户端
        write(connfd, sbuf, strlen(sbuf) + 1);
    }

    return 0;
}
客户端
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

int main(int argc, const char *argv[])
{
    // 创建 socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 连接服务器
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (connect(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("connect fail");
        return -1;
    }

    // 循环:从键盘读取 → 发给服务器 → 接收回发 → 打印
    char buf[1024] = {0};
    while (1)
    {
        printf("> ");
        fgets(buf, sizeof(buf), stdin);       // 从键盘获取输入
        write(fd, buf, strlen(buf) + 1);      // 发给服务器
        read(fd, buf, sizeof(buf));            // 接收服务器回发
        printf("recv: %s\n", buf);
    }

    return 0;
}

二、并发服务器

基础的服务器有一个严重问题:accept 之后进入通信循环,无法处理新的客户端连接。这就是"单循环服务器",同一时间只能服务一个客户端。

要支持多个客户端同时访问,就需要"并发服务器"。下面介绍几种实现方案。

2.1 单循环服务器的问题

// 单循环服务器 —— 只能串行处理
socket → bind → listen

while (1)
{
    connfd = accept(fd, ...);   // 阻塞等新连接
    while (1)                    // 和当前客户端通信
    {
        read(connfd, ...);
        write(connfd, ...);
        if (quit) break;
    }
}

特点:

  • 实现简单,可以处理多个客户端(一个接一个)
  • 无法同时处理:如果一个客户端通信耗时很长,其他客户端只能等待

2.2 多进程并发服务器

思路:每 accept 一个客户端,fork 一个子进程专门负责通信,父进程继续 accept 新连接。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <signal.h>
#include <sys/wait.h>
#include <stdlib.h>

// 信号处理函数:回收子进程资源,避免僵尸进程
void sighandler(int signo)
{
    wait(NULL);   // 非阻塞地回收任意一个已退出的子进程
}

int main(int argc, const char *argv[])
{
    // 创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 绑定地址
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (bind(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("bind fail");
        return -1;
    }

    // 监听
    if (listen(fd, 5) < 0)
    {
        perror("listen fail");
        return -1;
    }

    // 注册 SIGCHLD 信号处理函数
    // 子进程退出时会向父进程发送 SIGCHLD 信号
    // 在信号处理函数中调用 wait() 回收,避免子进程变成僵尸
    signal(SIGCHLD, sighandler);

    struct sockaddr_in cliaddr;
    socklen_t addrlen = sizeof(cliaddr);

    while (1)
    {
        // 阻塞等待新客户端连接
        int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
        if (connfd < 0)
        {
            perror("accept fail");
            return -1;
        }
        printf("accept success\n");
        printf("-------------------\n");
        printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
        printf("port        = %d\n", ntohs(cliaddr.sin_port));
        printf("-------------------\n");

        // fork 创建子进程
        pid_t pid = fork();

        if (pid == 0)
        {
            // ====== 子进程:负责与该客户端通信 ======
            // 子进程不需要监听 socket,可以关闭
            // (虽然不关也不会出错,但关闭是好习惯,节省资源)
            close(fd);

            char buf[1024] = {0};
            while (1)
            {
                read(connfd, buf, sizeof(buf));

                // 收到 quit 则关闭连接,子进程退出
                if (strncmp(buf, "quit", 4) == 0)
                {
                    close(connfd);
                    exit(0);    // 子进程退出,发送 SIGCHLD 给父进程
                }

                // 回发 "server + ..."
                char sbuf[1024] = {0};
                sprintf(sbuf, "server + %s\n", buf);
                write(connfd, sbuf, strlen(sbuf) + 1);
            }
        }
        // 父进程不关闭 connfd,直接回到 while(1) 继续 accept
        // 子进程会使用 connfd,父进程可以不管(子进程有 connfd 的副本)
    }

    return 0;
}

关键点:

  1. 1.子进程中 close(fd) —— 子进程不需要监听 socket
  2. 2.用 signal(SIGCHLD, sighandler) + wait(NULL) 回收子进程,避免僵尸
  3. 3.子进程用 exit(0) 退出(不是 break),退出时系统自动发送 SIGCHLD

2.3 多线程并发服务器

思路:与多进程类似,但用线程代替进程,开销更小。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <pthread.h>

// 线程处理函数:负责与单个客户端通信
// 参数 arg 指向 connfd
void *do_client(void *arg)
{
    // 取出 connfd(注意:需要拷贝值,不能直接用指针)
    // 因为外层循环中 connfd 可能会被覆盖
    int connfd = *(int *)arg;

    char buf[1024] = {0};

    // 通信循环
    while (1)
    {
        read(connfd, buf, sizeof(buf));

        if (strncmp(buf, "quit", 4) == 0)
            break;

        char sbuf[1024] = {0};
        sprintf(sbuf, "server + %s\n", buf);
        write(connfd, sbuf, strlen(sbuf) + 1);
    }

    // 通信结束,关闭 socket
    close(connfd);
    return NULL;
}

int main(int argc, const char *argv[])
{
    // 创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 绑定地址
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50000);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (bind(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("bind fail");
        return -1;
    }

    // 监听
    if (listen(fd, 5) < 0)
    {
        perror("listen fail");
        return -1;
    }

    struct sockaddr_in cliaddr;
    socklen_t addrlen = sizeof(cliaddr);

    while (1)
    {
        // 接受新客户端连接
        int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
        if (connfd < 0)
        {
            perror("accept fail");
            return -1;
        }
        printf("accept success\n");
        printf("-------------------\n");
        printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
        printf("port        = %d\n", ntohs(cliaddr.sin_port));
        printf("-------------------\n");

        // 创建线程处理该客户端
        pthread_t tid;
        // 将 connfd 的地址传给线程函数
        // 注意:这里存在竞态条件(下一次循环可能覆盖 connfd)
        // 生产环境中应使用动态分配的内存或线程参数结构体
        int ret = pthread_create(&tid, NULL, do_client, &connfd);
        if (ret != 0)
        {
            errno = ret;
            perror("pthread_create fail");
            return -1;
        }

        // 分离线程:线程结束后自动回收资源,不需要 pthread_join
        pthread_detach(tid);
    }

    return 0;
}

多线程 vs 多进程:

对比项多进程多线程
创建开销大(复制地址空间)小(共享地址空间)
资源回收需要处理 SIGCHLD / waitpthread_detach 自动回收
数据共享需要 IPC 机制全局变量直接共享(需注意同步)
稳定性一个崩溃不影响其他一个崩溃整个进程退出

2.4 并发方案总结

多进程:
    主进程:socket → bind → listen → accept(循环)
    子进程1:与客户端1通信
    子进程2:与客户端2通信
    ...
    宏观并行,微观由操作系统调度

多线程:
    主线程:socket → bind → listen → accept(循环)
    线程1:与客户端1通信
    线程2:与客户端2通信
    ...

三、I/O 模型

Linux 下有四种 I/O 模型,了解它们有助于理解后续的 I/O 多路复用。

3.1 阻塞 I/O(默认)

// 默认的 read/write/recv/send/fgets/scanf 都是阻塞的
// 没有数据时,程序卡在这里一直等,直到有数据到来
read(connfd, buf, sizeof(buf));   // 没数据就一直等

特点:简单,易用

3.2 非阻塞 I/O

方式一:通过 fcntl 修改文件描述符属性

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>

int main(int argc, const char *argv[])
{
    // socket → bind → listen → accept(同前,省略)

    int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);

    // ====== 将 connfd 设置为非阻塞模式 ======
    // 第1步:获取当前属性
    int flag;
    flag = fcntl(connfd, F_GETFL, 0);
    // 第2步:添加 O_NONBLOCK 标志
    flag = flag | O_NONBLOCK;
    // 第3步:将新属性写回
    fcntl(connfd, F_SETFL, flag);

    // 设置后,read 不再阻塞:
    // 有数据 → 直接读取
    // 没数据 → 立即返回 -1(errno = EAGAIN 或 EWOULDBLOCK)
    char buf[1024] = {0};
    while (1)
    {
        int ret = read(connfd, buf, sizeof(buf));
        printf("ret = %d buf = %s\n", ret, buf);  // 没数据时 ret=-1,会不断打印

        if (strncmp(buf, "quit", 4) == 0)
            break;

        char sbuf[1024] = {0};
        sprintf(sbuf, "server + %s\n", buf);
        write(connfd, sbuf, strlen(sbuf) + 1);
    }

    return 0;
}

特点:不阻塞,但需要轮询(循环不断尝试读取),CPU 空转浪费资源。

方式二:直接在 recv/send 中加 MSG_DONTWAIT 标志(单次非阻塞)

// setsockopt 设置端口复用(重启服务器时避免"地址已在使用"错误)
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (const void *)&on, sizeof(int));

// MSG_DONTWAIT 单次非阻塞读取
int ret = recv(connfd, buf, sizeof(buf), MSG_DONTWAIT);
printf("ret = %d buf = %s\n", ret, buf);

3.3 信号驱动 I/O

思路:给 socket 关联 SIGIO 信号,当 socket 上有数据到达时,内核自动发送 SIGIO 信号,程序在信号处理函数中读取数据。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>

int cfd = 0;   // 全局变量,保存 connfd 供信号处理函数使用

// SIGIO 信号处理函数:有数据到达时被调用
void sighandler(int signo)
{
    printf("-----hello SIGIO----\n");
    char buf[1024] = {0};

    // 在信号处理函数中读取数据
    int ret = read(cfd, buf, sizeof(buf));
    printf("ret = %d buf = %s\n", ret, buf);

    // 回发
    char sbuf[1024] = {0};
    sprintf(sbuf, "server + %s\n", buf);
    write(cfd, sbuf, strlen(sbuf) + 1);
}

int main(int argc, const char *argv[])
{
    // socket → bind → listen(同前,省略)

    // 注册 SIGIO 信号处理函数
    signal(SIGIO, sighandler);

    while (1)
    {
        int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
        if (connfd < 0)
        {
            perror("accept fail");
            return -1;
        }
        cfd = connfd;   // 保存到全局变量

        printf("accept success\n");
        printf("-------------------\n");
        printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
        printf("port        = %d\n", ntohs(cliaddr.sin_port));
        printf("-------------------\n");

        // 第1步:设置 socket 的"属主进程"——信号发给谁
        fcntl(connfd, F_SETOWN, getpid());

        // 第2步:开启 O_ASYNC 标志——允许 socket 产生 SIGIO 信号
        int flag;
        flag = fcntl(connfd, F_GETFL, 0);
        flag = flag | O_ASYNC;     // 开启异步信号
        fcntl(connfd, F_SETFL, flag);

        // 设置后,每当 connfd 上有数据到达,内核就会发送 SIGIO 信号
        // 程序不阻塞,信号处理函数会自动执行
    }

    return 0;
}

特点:高效,不需要阻塞等也不需要轮询。数据到达时通过信号"通知"程序。

3.4 I/O 模型总结

模型数据没到时特点
阻塞 I/O一直等简单,但线程被占用
非阻塞 I/O立即返回,循环轮询不阻塞,但 CPU 空转
信号驱动 I/O不等不轮询,来了信号通知高效
I/O 多路复用select 阻塞等,有就绪的才处理最常用,下面详解

四、I/O 多路复用 —— select

4.1 select 函数原型

#include <sys/select.h>

int select(
    int nfds,              // 最大的文件描述符 + 1
    fd_set *readfds,       // 监控"可读"事件的 fd 集合
    fd_set *writefds,      // 监控"可写"事件的 fd 集合(通常传 NULL)
    fd_set *exceptfds,     // 监控"异常"事件的 fd 集合(通常传 NULL)
    struct timeval *timeout // 超时时间
);

参数说明:

参数说明
nfds所有监控的 fd 中最大的那个 + 1
readfds关心哪些 fd 有数据可读
writefds关心哪些 fd 可以写入(通常 NULL)
exceptfds关心哪些 fd 出现异常(通常 NULL)
timeoutNULL → 阻塞等;时间 = 0 → 非阻塞;时间 > 0 → 等待指定秒数

返回值:

  • > 0:就绪的 fd 数量
  • = 0:超时,没有 fd 就绪
  • -1:出错,设置 errno

timeout 结构体:

struct timeval {
    long tv_sec;    // 秒
    long tv_usec;   // 微秒
};
struct timeval t = {3, 0};  // 超时 3 秒

4.2 fd_set 操作宏

void FD_ZERO(fd_set *set);       // 清空集合(全部删除)
void FD_SET(int fd, fd_set *set); // 将 fd 加入集合
void FD_CLR(int fd, fd_set *set); // 将 fd 从集合移除
int  FD_ISSET(int fd, fd_set *set); // 判断 fd 是否在集合中(就绪了)

4.3 select 使用模板

// 1. 准备监控集合
fd_set rdfds;        // 工作集合(每次 select 后会被修改)
fd_set backfds;      // 备份集合(保存完整的监控列表)
FD_ZERO(&backfds);
FD_SET(0, &backfds);       // 监控 stdin(键盘输入)
FD_SET(connfd, &backfds);  // 监控 socket(对端发来的数据)

int maxfd = connfd;  // 当前最大 fd

// 2. 主循环
while (1)
{
    // 每次循环恢复监控集合(select 会破坏 rdfds)
    rdfds = backfds;

    // select 阻塞等待
    int ret = select(maxfd + 1, &rdfds, NULL, NULL, NULL);
    if (ret < 0)
    {
        perror("select fail");
        break;
    }
    if (ret == 0)
    {
        // 超时,继续循环
        continue;
    }

    // 3. 遍历所有 fd,检查哪些就绪
    int i;
    for (i = 0; i <= maxfd; i++)
    {
        if (FD_ISSET(i, &rdfds))
        {
            if (i == 0)
            {
                // stdin 就绪:键盘有输入
                // fgets + write 发给对方
            }
            else if (i == connfd)
            {
                // socket 就绪:对方发来数据
                // read + printf 打印
            }
        }
    }
}

4.4 为什么需要 backfds?

// select 返回后会修改 rdfds,只留下就绪的 fd
// 例:监控 {0, connfd=4}
// select 返回后 rdfds = {4}(只有 connfd 就绪)
// 如果不备份,下次循环就不监控 stdin 了!

// 所以需要 backfds:
// backfds 保存完整的 {0, 4}
// 每次循环:rdfds = backfds;  // 恢复完整列表
// 再传给 select

4.5 用 select 实现客户端同时收发(点对点聊天)

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <sys/select.h>

int main(int argc, const char *argv[])
{
    // 创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }

    // 连接服务器
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50001);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (connect(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("connect fail");
        return -1;
    }
    printf("已连接服务器\n");

    // ====== select 同时监控 stdin 和 socket ======
    fd_set rdfds;
    fd_set backfds;
    FD_ZERO(&backfds);
    FD_SET(0, &backfds);    // 标准输入(键盘输入,即"发")
    FD_SET(fd, &backfds);   // 服务器发来的消息(即"收")
    int maxfd = fd;

    char buf[1024] = {0};
    printf("===== 聊天开始(输入quit退出)=====\n");

    while (1)
    {
        // 恢复监控集合
        rdfds = backfds;

        int ret = select(maxfd + 1, &rdfds, NULL, NULL, NULL);
        if (ret < 0)
        {
            perror("select fail");
            break;
        }

        // ====== 发:键盘输入 → 发给服务器 ======
        if (FD_ISSET(0, &rdfds))
        {
            memset(buf, 0, sizeof(buf));
            fgets(buf, sizeof(buf), stdin);

            if (strncmp(buf, "quit", 4) == 0)
            {
                write(fd, buf, strlen(buf) + 1);
                printf("quit...\n");
                break;
            }

            write(fd, buf, strlen(buf) + 1);
        }

        // ====== 收:socket 就绪 → 读取服务器消息 ======
        if (FD_ISSET(fd, &rdfds))
        {
            memset(buf, 0, sizeof(buf));
            int n = read(fd, buf, sizeof(buf));
            if (n <= 0)
            {
                printf("服务器已断开\n");
                break;
            }

            if (strncmp(buf, "quit", 4) == 0)
            {
                printf("server quit...\n");
                break;
            }

            printf("[ser] %s", buf);
        }
    }

    close(fd);
    return 0;
}

4.6 用 select 实现并发服务器

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>

int main(int argc, const char *argv[])
{
    // 创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 端口复用:避免服务器重启时"地址已在使用"
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (const void *)&on, sizeof(int));

    // 绑定地址
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(50001);
    seraddr.sin_addr.s_addr = inet_addr("192.168.10.129");

    if (bind(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("bind fail");
        return -1;
    }

    // 监听
    if (listen(fd, 5) < 0)
    {
        perror("listen fail");
        return -1;
    }

    struct sockaddr_in cliaddr;
    socklen_t addrlen = sizeof(cliaddr);

    // ====== select 初始化 ======
    // 最初只监控 listenfd(fd),等待新客户端连接
    fd_set rdfds;
    FD_ZERO(&rdfds);
    FD_SET(fd, &rdfds);

    // 备份
    fd_set backfds = rdfds;
    int nfds = fd + 1;  // 当前最大 fd + 1

    while (1)
    {
        // ① 每次循环恢复监控集合
        rdfds = backfds;

        // ② select 阻塞等待
        int ret = select(nfds, &rdfds, NULL, NULL, NULL);
        printf("ret = %d\n", ret);
        if (ret < 0)
        {
            perror("select fail");
            return -1;
        }

        if (ret > 0)
        {
            // ③ 遍历所有 fd,检查哪些就绪
            int i = 0;
            for (i = 0; i < nfds; ++i)
            {
                if (FD_ISSET(i, &rdfds))
                {
                    // ====== 情况A:listenfd 就绪 → 新客户端连接 ======
                    if (i == fd)
                    {
                        int connfd = accept(fd, (struct sockaddr *)&cliaddr, &addrlen);
                        if (connfd < 0)
                        {
                            perror("accept fail");
                            return -1;
                        }
                        printf("accept success\n");
                        printf("-------------------\n");
                        printf("cliaddr ip  = %s\n", inet_ntoa(cliaddr.sin_addr));
                        printf("port        = %d\n", ntohs(cliaddr.sin_port));
                        printf("-------------------\n");

                        // 将新客户端的 connfd 加入监控集合
                        FD_SET(connfd, &backfds);
                        // 更新 nfds
                        if (connfd + 1 > nfds)
                        {
                            nfds = connfd + 1;
                        }
                    }
                    // ====== 情况B:某个 connfd 就绪 → 客户端发来数据 ======
                    else
                    {
                        char buf[1024] = {0};
                        int ret = read(i, buf, sizeof(buf));
                        printf("ret = %d buf = %s\n", ret, buf);

                        if (strncmp(buf, "quit", 4) == 0)
                        {
                            // 客户端退出,从监控集移除,关闭 socket
                            FD_CLR(i, &backfds);
                            close(i);
                            continue;  // 跳过后面的回发
                        }

                        // 回发 "server + ..."
                        char sbuf[1024] = {0};
                        sprintf(sbuf, "server + %s\n", buf);
                        write(i, sbuf, strlen(sbuf) + 1);
                    }
                }
            }
        }
    }

    return 0;
}

4.7 select 的注意事项

  1. 1.backfds 必须备份:select 返回后会修改 rdfds,只保留就绪的 fd,必须用 backfds 每次恢复
  2. 2.nfds 要动态更新:新客户端连接时,如果 connfd + 1 > nfds,要更新 nfds
  3. 3.客户端退出要 FD_CLR:不再监控的 fd 要从集合移除
  4. 4.timeout 会被修改:Linux 下 select 返回时会把 timeout 改为剩余时间,重复使用需要注意
  5. 5.注意 perror 中的字符串:代码中 bind 失败的提示误写为 "connect fail",应改为 "bind fail"

4.8 select 的缺点

缺点说明
fd 数量受限每个 fd_set 最多监控 FD_SETSIZE 个 fd(Linux 默认 1024)
底层遍历内核通过遍历所有 fd 检查事件,fd 多时效率低
返回值需再判断返回的是就绪个数,还需要遍历所有 fd 用 FD_ISSET 逐个检查

后续的 epoll 解决了这些问题,适合大规模并发场景。


五、端口复用 setsockopt

服务器重启时,原来的端口可能处于 TIME_WAIT 状态,导致 bind 失败。用 setsockopt 设置 SO_REUSEADDR 可以解决:

int on = 1;   // 非0 表示开启功能
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, (const void *)&on, sizeof(int)) < 0)
{
    perror("setsockopt fail");
    return -1;
}

建议在 bind 之前调用。


六、HTTP 协议与天气查询实战

6.1 万维网与 HTTP 基础

万维网(WWW)由三个标准组成:

组件说明
URL(统一资源定位符)定位资源的地址,如 http://www.baidu.com:80/index.html
HTTP(超文本传输协议)规定浏览器和服务器之间如何传输数据
HTML(超文本标记语言)描述网页内容的格式

URL 一般形式:<协议>://<主机>:<端口>/<路径>

http://api.k780.com/?app=weather.today&weaid=chengdu&format=json
协议://  主机IP         :80  /?参数名=参数值&参数名=参数值

6.2 HTTP 特点

  • 请求-响应模式:客户端发请求,服务器回响应
  • 无状态:服务器不记住之前的交互
  • 基于 TCP:底层使用 TCP 可靠传输
  • 支持短连接(Connection: close)和长连接(Connection: keep-alive)

6.3 HTTP 报文格式

请求报文:

请求行:方法 URL 版本\r\n
首部行:键: 值\r\n
...
\r\n       ← 空行表示首部结束
[实体内容]

响应报文:

状态行:版本 状态码 短语\r\n
首部行:键: 值\r\n
...
\r\n       ← 空行表示首部结束
[实体内容]

6.4 HTTP 编程流程

1. 建立 TCP 连接(socket + connect)
2. 发送 HTTP 请求报文(send/write)
3. 接收 HTTP 响应报文(recv/read)
4. 解析响应中的数据
5. 关闭 TCP 连接

6.5 天气查询客户端完整代码

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

int main(int argc, const char *argv[])
{
    // 第1步:创建 TCP socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0)
    {
        perror("socket fail");
        return -1;
    }
    printf("fd = %d\n", fd);

    // 准备天气 API 服务器地址
    // api.k780.com 对应的 IP 为 103.205.5.206,端口 80(HTTP 默认端口)
    struct sockaddr_in seraddr;
    bzero(&seraddr, sizeof(seraddr));
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(80);                     // HTTP 默认端口
    seraddr.sin_addr.s_addr = inet_addr("103.205.5.206");

    // 第2步:建立 TCP 连接
    if (connect(fd, (const struct sockaddr *)&seraddr, sizeof(seraddr)) < 0)
    {
        perror("connect fail");
        return -1;
    }

    // 第3步:组织 HTTP 请求头,发送请求
    // 按照 HTTP 请求格式逐行发送:
    // 请求行\r\n
    // 首部行\r\n
    // ...
    // \r\n(空行表示首部结束)
    char arg[][256] = {
        // 请求行:方法 GET + URL + HTTP版本
        "GET /?app=weather.today&weaid=chengdu&appkey=10003&sign=b59bc3ef6191eb9f747dd4e83c99f2a4&format=json HTTP/1.1\r\n",
        // 首部行:主机名
        "Host: api.k780.com\r\n",
        // 首部行:客户端标识
        "User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0\r\n",
        // 首部行:接受的内容类型
        "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\r\n",
        "Accept-Language: en-US,en;q=0.5\r\n",
        "Accept-Encoding: gzip, deflate\r\n",
        // 首部行:连接方式(keep-alive 长连接,close 短连接)
        "Connection: keep-alive\r\n",
        "Upgrade-Insecure-Requests: 1\r\n",
        // 空行:表示首部结束
        "\r\n"
    };

    // 逐行发送请求头
    int i = 0;
    for (i = 0; i < sizeof(arg) / sizeof(arg[0]); ++i)
    {
        send(fd, arg[i], strlen(arg[i]), 0);
    }

    // 第4步:接收 HTTP 响应
    char buf[1024];
    recv(fd, buf, sizeof(buf), 0);
    printf("buf = %s\n", buf);

    // 第5步:解析响应数据
    // HTTP 响应中,响应头和 JSON 数据之间用 \r\n\r\n 分隔
    // 先定位到 JSON 数据(以 {" 开头的部分)
    char *p = buf;
    p = strstr(buf, "{\"");
    printf("p = %s\n", p);

    // 进一步解析 JSON 中的某个字段(如城市名 citynm)
    p = strstr(p, "citynm");
    printf("p = %s\n", p);

    // 用 strtok 按逗号分割字段
    p = strtok(p, ",");
    printf("p = %s\n", p);

    // 用 index 定位冒号,获取键值对中的值
    p = index(p, ':');
    printf("p = %s\n", p + 1);  // 跳过冒号和引号,输出城市名

    return 0;
}

运行结果(解析出城市名):

p = "citynm":"成都"

6.6 字符串解析辅助函数

#include <strings.h>

// index:在字符串 s 中查找字符 c 第一次出现的位置
char *index(const char *s, int c);

// rindex:在字符串 s 中查找字符 c 最后一次出现的位置
char *rindex(const char *s, int c);

// strstr:在 haystack 中查找子串 needle
char *strstr(const char *haystack, const char *needle);

// strtok:按分隔符切割字符串
char *strtok(char *str, const char *delim);

// 例:
char buf[] = "hello";
char *p = rindex(buf, 'l');
printf("p = %s\n", p);   // 输出 "lo"

6.7 TCP 三次握手与四次挥手

三次握手(建立连接):

客户端                  服务器
  ---- SYN=1,seq=x ---→     // 请求连接
  ←--- SYN=1,ACK=1,seq=y,ack=x+1 ---   // 服务器确认并回应
  ---- ACK=1,ack=y+1 ---→   // 客户端确认

四次挥手(关闭连接):

客户端                  服务器
  ---- FIN ---→              // 客户端请求关闭
  ←--- ACK ---               // 服务器确认收到
  ←--- FIN ---               // 服务器也请求关闭
  ---- ACK ---→              // 客户端确认

七、天气查询项目设计

7.1 功能菜单

+-------------------+
| 1. 配置城市       |
| 2. 查看实时天气   |
| 3. 查看未来一周天气 |
| 4. 退出           |
+-------------------+

7.2 各功能说明

1. 配置城市:设置当前查询的城市名称,后续天气查询均使用该城市。

2. 查看实时天气:打印当前城市的天气信息:

城市:成都
日期:2026-08-26
温度:34℃/26℃
当前温度:31℃
湿度:70%
天气:雷阵雨转多云
风力:南风 1级

3. 查看未来一周天气:查询未来天气接口,打印多天天气数据。

7.3 API 接口说明

  • 实时天气:http://api.k780.com/?app=weather.today&weaid=城市名&appkey=...&sign=...&format=json
  • 未来天气:http://api.k780.com/?app=weather.future&weaid=城市名&appkey=...&sign=...&format=json

7.4 连接方式

  • 短连接(Connection: close):每次查询都重新建立 TCP 连接
  • 长连接(Connection: keep-alive):一次连接可多次查询,效率更高

7.5 JSON 数据结构示例

{
    "success": "1",
    "result": {
        "weaid": "265",
        "days": "2026-08-26",
        "week": "星期三",
        "cityno": "chengdu",
        "citynm": "成都",
        "cityid": "101270101",
        "temperature": "34℃/26℃",
        "temperature_curr": "31℃",
        "humidity": "70%",
        "aqi": "35",
        "weather": "雷阵雨转多云",
        "weather_curr": "阴",
        "weather_icon": "http://api.k780.com/upload/weather/d/2.gif",
        "wind": "南风",
        "winp": "1级",
        "temp_high": "34",
        "temp_low": "26",
        "temp_curr": "31"
    }
}

八、完整总结

8.1 TCP 编程速查表

客户端:                              服务器:
socket(AF_INET, SOCK_STREAM, 0)      socket(AF_INET, SOCK_STREAM, 0)
                                     setsockopt(SO_REUSEADDR)  // 可选
connect(fd, &seraddr, ...)           bind(fd, &seraddr, ...)
                                     listen(fd, 5)
                                     connfd = accept(fd, &cliaddr, &len)
read/write 或 recv/send              read/write 或 recv/send
close(fd)                            close(connfd); close(fd)

8.2 并发方案对比

方案原理优点缺点
单循环accept 后串行通信简单无法同时处理多个客户端
多进程fork 子进程通信隔离性好开销大,需处理僵尸进程
多线程创建线程通信开销小需注意线程安全
selectI/O 多路复用,单进程监控多 fd无额外进程/线程开销fd 数量受限(1024),遍历效率低
信号驱动SIGIO 信号通知高效编程复杂

8.3 select 使用要点

① 用 backfds 备份,每次循环 rdfds = backfds 恢复
② 动态更新 nfds(新连接时比较大小)
③ 客户端退出时 FD_CLR 从集合移除
④ timeout 为 NULL 阻塞等,为 0 非阻塞,大于 0 超时等
⑤ select 返回值:>0 就绪数,=0 超时,-1 错误
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐