本文主题内容
- 理解 TCP Socket 的客户端与服务器流程
- 掌握
listen、accept和connect - 区分监听 Socket 与已连接 Socket
- 实现 TCP 回显服务器和客户端
- 理解短读、短写与连接关闭
- 对比单进程、多进程和多线程服务器
- 掌握 TCP Socket 常见错误处理
引言:TCP 是面向连接的字节流协议。服务器不仅要创建和绑定 Socket,还要进入监听状态,再为每个客户端建立独立的已连接 Socket。理解这两个文件描述符的职责,是写对 TCP 服务器的第一步。
一、TCP Socket 通信流程
1.1 服务器流程
TCP 服务器通常执行:
socket:创建监听 Socketbind:绑定本地 IP 和端口listen:把 Socket 设置为监听状态accept:从已完成连接队列中取出一个连接read/recv与write/send:通过已连接 Socket 通信close:关闭连接
1.2 客户端流程
客户端通常执行:
socket:创建 Socket- 准备服务器地址
connect:主动发起连接- 进行读写
close:关闭连接
connect 成功返回时,TCP 三次握手已经完成,Socket 可以进行双向读写。
二、TCP 核心接口
2.1 listen
c
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen 把已绑定的流式 Socket 转换为监听 Socket。backlog 与内核连接队列容量有关,但不是简单等同于服务器最多能连接多少客户端,实际行为还受操作系统参数影响。
2.2 accept
c
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept 返回一个新的文件描述符,用于和本次接入的客户端通信。原监听 Socket 继续监听后续连接。
监听 Socket 负责获取新连接,accept 返回的已连接 Socket 负责和某个客户端收发数据。
例:
c
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int connfd = accept(listenfd, (struct sockaddr *)&peer, &len);
if (connfd < 0)
{
perror("accept");
}
addrlen 同样是传入传出参数,调用前必须初始化。
2.3 connect
c
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
客户端调用 connect 时,内核会选择本地 IP 和临时端口,向服务器发起连接。阻塞模式下,连接成功或失败后接口才返回。
三、TCP 回显服务器
例:
cpp
#include <arpa/inet.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
bool SendAll(int fd, const char *data, size_t size)
{
size_t offset = 0;
while (offset < size)
{
ssize_t n = send(fd, data + offset, size - offset, 0);
if (n > 0)
{
offset += static_cast<size_t>(n);
continue;
}
if (n < 0 && errno == EINTR)
{
continue;
}
return false;
}
return true;
}
void Service(int connfd)
{
char buffer[4096];
for (;;)
{
ssize_t n = recv(connfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
if (!SendAll(connfd, buffer, static_cast<size_t>(n)))
{
break;
}
}
else if (n == 0)
{
std::cout << "peer closed\n";
break;
}
else
{
if (errno == EINTR)
{
continue;
}
std::cerr << "recv: " << std::strerror(errno) << '\n';
break;
}
}
}
int main(int argc, char *argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port\n";
return 1;
}
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0)
{
return 2;
}
int reuse = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[1])));
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listenfd, reinterpret_cast<sockaddr *>(&local), sizeof(local)) < 0)
{
std::cerr << "bind: " << std::strerror(errno) << '\n';
close(listenfd);
return 3;
}
if (listen(listenfd, 128) < 0)
{
close(listenfd);
return 4;
}
for (;;)
{
sockaddr_in peer{};
socklen_t len = sizeof(peer);
int connfd = accept(listenfd, reinterpret_cast<sockaddr *>(&peer), &len);
if (connfd < 0)
{
if (errno == EINTR)
{
continue;
}
std::cerr << "accept: " << std::strerror(errno) << '\n';
continue;
}
Service(connfd);
close(connfd);
}
}
这个版本一次只处理一个客户端,适合理解接口,不适合高并发场景。
四、TCP 客户端
例:
cpp
#include <arpa/inet.h>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc != 3)
{
return 1;
}
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
return 2;
}
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
if (inet_pton(AF_INET, argv[1], &server.sin_addr) != 1)
{
close(sockfd);
return 3;
}
if (connect(sockfd, reinterpret_cast<sockaddr *>(&server), sizeof(server)) < 0)
{
close(sockfd);
return 4;
}
std::string line;
while (std::getline(std::cin, line))
{
line.push_back('\n');
send(sockfd, line.data(), line.size(), 0);
char buffer[4096];
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n <= 0)
{
break;
}
buffer[n] = '\0';
std::cout << buffer;
}
close(sockfd);
return 0;
}
实际协议不能假设一次 send 对应一次 recv。这里使用换行只为了演示,生产代码必须明确消息边界。
五、字节流与短读短写
5.1 TCP 没有消息边界
TCP 只提供连续、有序的字节流:
- 一次发送可能被拆成多次接收
- 多次发送也可能被一次接收读到
recv返回的是当前可用字节数,不代表一条业务消息结束
因此应用层必须设计协议,例如:
- 固定长度
- 特殊分隔符
- 长度字段加正文
- 自描述格式
5.2 send 不保证一次写完
阻塞 Socket 的 send 也可能只接受部分数据。发送完整缓冲区需要循环处理返回值,不能简单认为返回正数就全部成功。
5.3 recv 返回 0
recv 返回 0 表示对端已经有序关闭发送方向。此时应停止读取并释放连接资源。
注意:把 recv 返回 0 当作暂时没有数据,会导致服务器反复处理一个已经关闭的连接。
六、并发服务器
6.1 多进程
父进程负责 accept,每个连接交给子进程:
- 父进程关闭子进程使用的
connfd - 子进程关闭继承的
listenfd - 父进程需要回收子进程,防止僵尸进程
多进程隔离性好,但创建和切换成本较高。
6.2 多线程
每个连接创建一个线程:
- 线程参数必须拥有稳定生命周期
- 工作线程结束后关闭自己的
connfd - 线程必须
join或detach - 多线程共享全局状态时需要同步
连接数量很大时,一连接一线程会消耗大量栈和调度资源,通常会进一步使用线程池或 IO 多路复用。
6.3 任务与通信分离
更可扩展的结构是:
text
监听线程 -> 接收连接 -> 投递任务 -> 工作线程处理
但不能把同一个连接无序交给多个工作线程,否则可能破坏请求顺序和连接状态。任务边界、连接所有权和关闭协议必须明确。
七、连接关闭与异常
7.1 close 与 shutdown
close 释放文件描述符引用。shutdown 可以只关闭读方向或写方向:
c
int shutdown(int sockfd, int how);
SHUT_RD:关闭读方向SHUT_WR:关闭写方向,并发送 FINSHUT_RDWR:关闭两个方向
半关闭适合发送完请求后通知对方不会再发送,但仍继续读取响应。
7.2 SIGPIPE
向已经关闭的连接写数据可能触发 SIGPIPE,默认动作是终止进程。服务器通常需要忽略该信号,或使用 MSG_NOSIGNAL,并通过返回值处理 EPIPE。
7.3 SO_REUSEADDR
服务器重启时可能遇到地址仍在使用。SO_REUSEADDR 可以改善常见的重启绑定问题,但它不会消除 TCP 状态机,也不能让多个普通监听 Socket 随意绑定同一地址。
八、常见问题
- 忘记调用
listen - 把
accept返回值当成监听 Socket - 处理完连接后忘记关闭
connfd - 没有循环处理短写
- 把一次
recv当作一条完整请求 - 参数对象已经销毁,工作线程仍在访问
- 服务器在回调中执行长时间任务,阻塞后续连接
- 把未经认证的网络输入直接交给 shell 执行
最后一项尤其危险。远程命令服务必须设计认证、授权、命令白名单、审计和隔离,不能把任意字符串直接传给 popen 或 system。
九、总结
TCP 服务器通过监听 Socket 接收新连接,通过 accept 返回的已连接 Socket 与单个客户端通信。客户端使用 connect 主动建立连接,随后双方都可以使用普通读写接口传输字节流。
TCP 提供可靠有序的字节流,但不提供应用消息边界。程序必须正确处理短读、短写、对端关闭、SIGPIPE 和资源回收。并发模型可以从单进程逐步扩展到多进程、多线程、线程池和 IO 多路复用。
写对 TCP 服务器的关键,不是把接口按顺序调用一次,而是管理好监听描述符、连接描述符、字节流边界和每条连接的完整生命周期。