一、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.子进程中
close(fd)------ 子进程不需要监听 socket - 2.用
signal(SIGCHLD, sighandler)+wait(NULL)回收子进程,避免僵尸 - 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.backfds 必须备份 :
select返回后会修改rdfds,只保留就绪的 fd,必须用backfds每次恢复 - 2.nfds 要动态更新 :新客户端连接时,如果
connfd + 1 > nfds,要更新nfds - 3.客户端退出要 FD_CLR:不再监控的 fd 要从集合移除
- 4.timeout 会被修改 :Linux 下
select返回时会把timeout改为剩余时间,重复使用需要注意 - 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 错误