TCP 网络编程(3):从基础通信到并发服务器与 HTTP 实战

一、TCP 基础通信回顾

1.1 TCP 编程流程

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

1.2 字节序转换函数

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

cs 复制代码
#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 服务器(无通信)

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

cs 复制代码
#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 客户端

cs 复制代码
#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 简单回发服务器与客户端

服务器(回发)
cs 复制代码
#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;
}
客户端
cs 复制代码
#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 单循环服务器的问题

cs 复制代码
// 单循环服务器 ------ 只能串行处理
socket → bind → listen

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

特点

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

2.2 多进程并发服务器

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

cs 复制代码
#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 多线程并发服务器

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

cs 复制代码
#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 / wait pthread_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 修改文件描述符属性

cs 复制代码
#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 信号,程序在信号处理函数中读取数据。

cs 复制代码
#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)
timeout NULL → 阻塞等;时间 = 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 使用模板

cs 复制代码
// 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 实现客户端同时收发(点对点聊天)

cs 复制代码
#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 实现并发服务器

cs 复制代码
#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 天气查询客户端完整代码

cs 复制代码
#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 字符串解析辅助函数

cs 复制代码
#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 子进程通信 隔离性好 开销大,需处理僵尸进程
多线程 创建线程通信 开销小 需注意线程安全
select I/O 多路复用,单进程监控多 fd 无额外进程/线程开销 fd 数量受限(1024),遍历效率低
信号驱动 SIGIO 信号通知 高效 编程复杂

8.3 select 使用要点

复制代码
① 用 backfds 备份,每次循环 rdfds = backfds 恢复
② 动态更新 nfds(新连接时比较大小)
③ 客户端退出时 FD_CLR 从集合移除
④ timeout 为 NULL 阻塞等,为 0 非阻塞,大于 0 超时等
⑤ select 返回值:>0 就绪数,=0 超时,-1 错误
相关推荐
Discipline~Hai18 分钟前
Linux应用软件编程04-线程
linux·服务器·c语言·开发语言·linux应用软件编程
charlie11451419123 分钟前
Cinux · musl 静态移植:对齐 Linux ABI、铺初始栈,以及一个被 SMAP 拦下的潜伏 bug
linux·开发语言·c++·操作系统·开源项目
流浪00138 分钟前
C/C++后端筑基系列(二):C++ 从基础到进阶
开发语言·c++
m0_5474866610 小时前
《JavaScript核心原理 》全套PPT课件2026
开发语言·javascript·ecmascript
MC皮蛋侠客11 小时前
Tauri 2.x 系列(五):调用系统 API——官方插件、Rust crate 与原生能力
开发语言·后端·rust
布莱克60511 小时前
软中断和硬中断的区别
开发语言·操作系统
whcyhhh12 小时前
头歌实践教学平台:数据科学与大数据技术导论(十二)
大数据·开发语言·python·数据清洗
lzhdim12 小时前
13、JavaScript事件循环机制 - JavaScript学习系列文章
开发语言·前端·javascript·学习·ecmascript
zbyyd13 小时前
Linux 多线程:互斥锁 &amp; 信号量
linux·c语言·开发语言·网络