本篇主线
| 环节 | 要解决的问题 | 结论 |
|---|---|---|
| 差异盘点 | TCP 比 UDP 多做什么 | 多 listen / accept(服务端)、connect(客户端),读写接口反而变简单 |
| ★ 认知一 | listen 到底在干什么 |
把套接字设成随时等人来连的状态:「餐厅老板必须守在店里」 |
| ★★ 认知二 | 为什么没调 accept 也能连上 |
三次握手由内核完成,与 accept 无关 ;accept 只是从内核里取走一条已建好的连接 |
| ★★ 认知三 | accept 为什么要返回新的 fd |
监听 fd 只负责「拉客」,新 fd 才负责「服务」------两个 fd 是两种角色 |
| 建立连接 | 谁在做三次握手 | 内核。listen 一设好,连接就能建立,只是没人接管 |
| 服务实现 | 服务端怎么写 | Init() 三步 + Run() 里 accept → service + service 里 read → 判三态 → write |
| ★ 读的语义 | TCP 的 read 怎么判结束 |
返回 0 = 对端关闭连接,这是客户端退出的唯一信号 |
| 客户端 | 要不要 bind / listen |
都不要;connect 时内核自动选本地 IP 与随机端口 |
| ★ 单进程死穴 | 为什么只能服务一个人 | service 里是死循环,再没回到 accept |
一、TCP 与 UDP:接口的差别其实很小
UDP 是面向数据报、无连接 的通信方式:套接字建好,直接发就行。
TCP 是面向连接的:通信之前,必须先与对端建立连接。
「要建立连接」这一条要求,直接推出了 TCP 独有的三个动作:
| 步骤 | UDP | TCP | 差别 |
|---|---|---|---|
| 创建 | socket(AF_INET, SOCK_DGRAM, 0) |
socket(AF_INET, SOCK_STREAM, 0) |
★ 只差这一个枚举 |
| 绑定 | bind |
bind |
一字不改 |
| 设为可被动连接 | ✖ 没有 | ✔ listen |
TCP 独有 |
| 拿一条连接 | ✖ 没有 | ✔ accept |
TCP 独有,返回新 fd |
| 主动建立连接 | ✖ 没有 | ✔ connect |
客户端独有 |
| 收 | recvfrom(要带地址) |
read / recv(不带地址) |
参数更少 |
| 发 | sendto(要带地址) |
write / send(不带地址) |
参数更少 |
💡 一句话概括:TCP 在「连接管理」上多了三个接口,在「数据收发」上少了两组参数。收发之所以能简化,是因为连接一旦建立,对端就是唯一的、内核早就记好了四元组------地址参数成了废话。
二、工程骨架:把公共约定抽出来
2.1 文件结构
| 文件 | 职责 |
|---|---|
common.hpp |
退出码枚举 + NoCopy 基类 + 公共头文件 |
tcp_server.hpp |
TcpServer 类声明 |
tcp_server.cc |
TcpServer 实现(服务端做封装) |
tcp_client.cc |
客户端(不做封装,面向过程直接写) |
Makefile |
一次产出 tcp_server 与 tcp_client 两个可执行文件 |

2.2 退出码:不硬编码
上一版 UDP 代码退出时直接 exit(1)、exit(2) 硬编码数字。这种写法有两个问题:
| 问题 | 后果 |
|---|---|
| 数字本身没有语义 | 三个月后回看,exit(3) 是哪一步失败?得翻代码 |
| 分散在各处 | 想加一个退出原因,得先全文搜一遍有没有撞号 |
改成集中管理的枚举:
cpp
// common.hpp
enum class ExitCode
{
OK = 0, // 正常
USAGE_ERR, // 命令行参数用错
SOCKET_ERR, // 创建套接字失败
BIND_ERR, // 绑定失败
LISTEN_ERR, // 设为监听失败
ACCEPT_ERR, // 获取连接失败(非致命)
CONNECT_ERR // 客户端发起连接失败
};
cpp
exit(static_cast<int>(ExitCode::BIND_ERR));
💡 起始值给 0、后面依次排开,是刻意为之:枚举默认从 0 递增,往后每加一项自动拿下一个值,不需要手工维护序号。这和日志等级用常量而不用数字是同一个思路。
于是程序退出后,只看退出码就知道卡在哪一步:
bash
./tcp_server 8080 ; echo "exit=$?"
2.3 NoCopy:把「禁止拷贝」变成可复用的能力
服务器的语义决定了它不该被拷贝------从一个服务器对象复制出另一个服务器对象,没有任何意义,而且几乎必然是 bug。
最直接的做法是把拷贝构造和赋值在类里设成 private。但它不优雅:
| 写法 | 问题 |
|---|---|
在 TcpServer 里 private 掉 |
TCP 服务器不能拷了,但 UDP 服务器也想禁止拷贝,就得再抄一遍 |
抽成基类 NoCopy |
谁想禁止,public NoCopy 继承一下即可 |
cpp
// common.hpp
class NoCopy
{
public:
NoCopy() = default;
~NoCopy() = default;
NoCopy(const NoCopy&) = delete; // 拷贝构造删掉
NoCopy& operator=(const NoCopy&) = delete; // 拷贝赋值删掉
};
cpp
class TcpServer : public NoCopy
{
// ...
};
★ 继承为什么就管用:派生类要生成自己的拷贝构造,必须先把基类拷贝一份 ;而基类的拷贝构造已经被 delete 掉了,这一步根本做不到,于是派生类的拷贝构造被编译器自动删除。一行继承,整条继承链都禁止拷贝。
2.4 服务端封装、客户端不封装
| 角色 | 是否封装 | 原因 |
|---|---|---|
| 服务端 | ✔ 封装成 TcpServer 类 |
有状态(listen fd、端口、运行标志)、生命周期长、要反复复用 |
| 客户端 | ✖ 面向过程直接写 | 流程线性(连上→发→收→退),封装收益低 |
三、socket():TCP 只差一个参数
c
int socket(int domain, int type, int protocol);
| 参数 | UDP 取值 | TCP 取值 | 说明 |
|---|---|---|---|
domain |
AF_INET |
AF_INET |
协议家族,底层都是 IPv4 |
type |
SOCK_DGRAM |
SOCK_STREAM |
★ 唯一的差别 |
protocol |
0 |
0 |
家族 + 类型已经唯一确定协议,写 0 即可 |
SOCK_STREAM 这个类型的四个定语值得记住:
| 定语 | 含义 |
|---|---|
| 有序 | 字节按发送顺序到达,不乱序 |
| 可靠 | 丢包会重传,不会静默丢失 |
| 全双工 | 同一条连接两个方向可以同时发数据 |
| 面向连接 | 必须先建连才能收发 |
3.1 socket 就是「打开一个网络文件」
| 步骤 | 内核实际做的事 |
|---|---|
| ① | 创建一个 struct file 对象来描述这个网络文件 |
| ② | 在进程的文件描述符表 里找一个没有使用的最小下标 |
| ③ | 把这个下标返回给调用方 |
于是有一个可以直接验证的推论:
| 事实 | 结论 |
|---|---|
| 进程启动时 0 / 1 / 2 已被标准输入、输出、错误占用 | 第一个打开的 socket 一定是 3 |
运行日志里打出来的 listen sockfd: 3,就是这么来的。
💡 「socket 的文件怎么和网络关联起来」这件事本篇不展开,但可以先记住这个态度:它就是一个文件描述符 ,凡是文件能做的事------read / write / close------对它都能做。后面 TCP 的收发之所以直接用 read / write,根子就在这儿。
四、bind():复用封装好的地址类
上一版手写 sockaddr_in 要填一堆字段,还要 htons、inet_addr,很容易漏。既然之前已经封装了 InetAddr,这一步就可以直接复用:
cpp
InetAddr local(_port); // 只给端口
if (bind(_listensockfd, local.Addr(), local.Length()) < 0) // 失败就是致命错误
{
LOG(FATAL) << "bind failed";
exit(static_cast<int>(ExitCode::BIND_ERR));
}
| 用到的成员 | 作用 |
|---|---|
InetAddr(port) |
重载构造:只给端口时,sin_addr 填 INADDR_ANY |
Addr() |
返回 const struct sockaddr*(内部已经做过强转 ,外面不用再写 (struct sockaddr*)) |
Length() |
返回 sizeof(struct sockaddr_in) |
★ 这一步真正省掉的东西:不用再写 struct sockaddr_in addr; addr.sin_family = ...; addr.sin_port = htons(...); addr.sin_addr.s_addr = ...; 这一整套,也不用记 bind 的第二个参数要强转成 struct sockaddr*。
4.1 为什么用 INADDR_ANY 而不是某个具体 IP
| 绑定方式 | 效果 |
|---|---|
绑 0.0.0.0(INADDR_ANY) |
凡是发到本机任意一张网卡、任意一个 IP 的 8080 端口,全部收下 |
| 绑某个具体 IP | 只有发到那个 IP 的才收,其他 IP 上的连接一律不理 |
服务器推荐前者:公网 IP、内网 IP、127.0.0.1 全都能进来,不用关心客户端从哪条路摸过来。
4.2 端口号的选择
| 端口范围 | 说明 |
|---|---|
0 ~ 1023 |
知名端口,需要超级用户权限 ,普通用户 bind 直接失败 |
1024 ~ 65535 |
普通用户可以绑定,服务器一般选这区间 |
实测:绑 1000 失败、绑 1023 失败、绑 1024 成功。
五、★ listen():服务器从「关着门」变成「开着门」
5.1 一个类比先建立直觉
| 场景 | 要不要一直守着 |
|---|---|
| 寄信 / 快递 / 邮件 | 不用。东西放那儿,闲的时候自己去取就行 |
| 开餐厅 | 必须。老板每天得守在店里,因为随时可能有客人上门 |
- 「放那儿自己去取」→ UDP,面向数据报,无连接,来了就扔在缓冲区里
- 「老板必须守在店里」→ TCP,面向连接,必须随时准备好接客
所以 TCP 服务器初始化时,必须主动把自己的状态设成「监听」 ,等别人来连。UDP 没有这一步------建好套接字直接 recvfrom 就行。
5.2 接口与 backlog
c
int listen(int sockfd, int backlog);
| 参数 | 说明 |
|---|---|
sockfd |
已经 bind 好的监听套接字 |
backlog |
底层允许排队的连接数上限 |
backlog 的取值有讲究:
| 取值 | 后果 |
|---|---|
| 不设 / 给 0 | 连接一多就被拒,实际等于没队列 |
| 几万、十万 | 不行,内核资源会吃紧,且没有意义 |
| 成熟软件的做法 | Redis 之类通常给 32 / 16 这种量级 |
| 教学 / 低配机器 | 给 8 就够观察现象 |
cpp
const static int DefaultBacklog = 8;
if (listen(_listensockfd, DefaultBacklog) < 0)
{
LOG(FATAL) << "listen failed";
exit(static_cast<int>(ExitCode::LISTEN_ERR));
}
5.3 实测:只 listen,什么都不做
把 Init() 里 socket → bind → listen 三步写完,Run() 里先放一个空循环(什么都不干),编译运行:
$ ./tcp_server 8080
create socket success, sockfd: 3
bind success
listen success
另开一个终端查:
$ netstat -nltp
Proto Local Address State PID/Program name
tcp 0.0.0.0:8080 LISTEN 108682/./tcpserver
★ 关键就在 LISTEN 这个状态上:代码里一个业务逻辑都没有,但服务器已经准备好接客了。
六、★★ 结论一:只要处于 listen,就已经可以被连接了
6.1 用 telnet 直接连
telnet 是一个「远程登录指定 TCP 服务」的工具,它不关心对面是 HTTP 还是自定义协议------只要是 TCP 就能连。
$ telnet 127.0.0.1 8080
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
代码里连 accept 都还没写,Connected 已经出现了。
💡 telnet 还能当简易 HTTP 客户端用,这件事本身就说明「网页就是跑在 TCP 上的字节流」:
$ telnet www.baidu.com 80
GET / HTTP/1.1
Host: www.baidu.com
(空行再回车)
粘贴回来的就是一整页 HTML。之所以要先有 GET 和 Host 这个格式,是因为 HTTP 只是 TCP 上面的一层约定------协议是上层的事,TCP 只负责把字节可靠地送过去。
6.2 连接建立后 netstat 里有什么
$ netstat -natp | grep 8080
Proto Local Address Foreign Address State PID/Program name
tcp 0.0.0.0:8080 0.0.0.0:* LISTEN 108682/./tcpserver
tcp 127.0.0.1:58752 127.0.0.1:8080 ESTABLISHED 108746/telnet
tcp 127.0.0.1:8080 127.0.0.1:58752 ESTABLISHED -
ESTABLISHED 就是「连接已建立」。★ 一条客户端、一条服务端,两条记录的四元组正好互为镜像。
6.3 为什么是两条而不是一条
| 原因 | 说明 |
|---|---|
| a. TCP 是全双工 | 每条连接必然有两个方向相反的通路,两端各有各的 socket |
| b. 客户端和服务端在同一台机器上 | ★ 主要原因。两个 socket 都在这台机器上,所以两条记录都能被 netstat 看到 |
如果服务端部署在另一台机器上、客户端在本机,那么本机只能看到自己这一侧的那一条。
6.4 结论是怎么得出来的
| 观察 | 推论 |
|---|---|
没写 accept,连接照样变成 ESTABLISHED |
建立连接这件事不需要 accept 参与 |
连接的信息在 netstat 里查得到,而进程没提供过 |
这些连接信息存在内核里 |
调 accept 时能拿到这条连接 |
所以 accept 的动作是从内核里取,不是「建立」 |
★ 一个足够强的表述:accept 获取的链接,是从内核中直接获取的;建立连接的过程和 accept 无关。

这张图把整件事串起来了:三次握手全在内核 里完成,完成的连接排进全连接队列 ;accept 的工作只是伸头进队列,取走一条已经是 ESTABLISHED 的记录。
| 动作 | 谁在做 | 与 accept 的关系 |
|---|---|---|
| 收到 SYN、回 SYN+ACK、收 ACK | 内核 | 无关 |
| 把完成的连接放进全连接队列 | 内核 | 无关 |
| 从队列头取出一条,返回新 fd | 进程调 accept |
就是它 |
由此还能解释两个现象:
| 现象 | 原因 |
|---|---|
不调 accept,连接也能建成功 |
建连在内核,进程不参与 |
accept 调用得慢一点也没事 |
期间到达的连接会在队列里等着,直到队列满 |
accept 很快也没用 |
队列空时它只能阻塞等待,快不过内核建连的速度 |
七、★ accept():拿到的是一条「新的」连接
c
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
| 参数 | 方向 | 说明 |
|---|---|---|
sockfd |
入 | 监听套接字 |
addr |
出 | 输出对端(客户端)的地址信息 |
addrlen |
入 + 出 | 传入缓冲区大小,返回实际写入的长度 |
| 返回值 | --- | 成功返回一个新的文件描述符 ;失败返回 -1 并设置 errno |
后两个参数的作用,和 UDP 里 recvfrom 的后两个参数完全对应:都是在回答「这条消息 / 这条连接是谁发来的」。
7.1 addr / addrlen 什么时候可以不传
| 需求 | 写法 |
|---|---|
| 想知道是谁连上来的 | 传 &peer 和 &len |
| 不关心对端是谁 | 两个都传 NULL |
7.2 复用 InetAddr 解析对端地址
accept 输出的是原始 sockaddr_in,但既然 InetAddr 已经能「把网络地址传进来 → 直接拿 IP / 端口」,那就再构造一个即可:
cpp
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if (sockfd < 0)
{
LOG(WARNING) << "accept failed"; // 注意:不是致命错误
continue; // 直接去拿下一个
}
InetAddr client(peer); // 用重载构造把 sockaddr_in 转成可读的 IP + 端口
LOG(INFO) << "get a new link: " << client.Ip() << ":" << client.Port();
★ 这里有个实测踩过的坑:addrlen 必须传 sizeof(struct sockaddr_in) ,而不是 sizeof(sockaddr)。如果传错,打印出来的 IP 会变成类似 253.127.0.0 这种明显不对的值------原因:accept知道"目标缓冲区"到底有多大,也就是ip的大小:16字节,你如果直接把整个sockaddr传进去,大小跟实际给的指针不对应,系统会误以为有其他数据,会给出错误的数字。
7.3 ★ accept 失败不是致命错误
对比两类失败:
| 失败点 | 性质 | 处理 |
|---|---|---|
socket / bind / listen 失败 |
致命。服务器根本起不来 | 打日志 + exit |
accept 失败 |
非致命。只是这一次没拿到 | 打日志 + continue 继续下一轮 |
用鱼庄的话说:张三上去招呼,客人说「刚吃过」扭头就走。张三不会因此怀疑人生、更不会辞职不干------他转头去招呼下一个人。
八、好再来鱼庄:一次讲透两个 fd
accept 的返回值是一个新的文件描述符,这是 TCP 和 UDP 差别最大的地方。UDP 从头到尾就一个 fd,TCP 却会越用越多。为什么?
8.1 故事
杭州西湖边上一条街,全是饭庄、鱼庄,卷到每家门口都站一个人招呼客人 。只看其中一家------好再来鱼庄 。门口站的那位叫张三。
| 情节 | 对应 |
|---|---|
| 好再来鱼庄 | 代码里那个服务器进程 |
| 门口那条马路、街上的人流 | 操作系统内部 |
| 张三站在门口招呼 | 服务器处于 listen 状态 |
| 张三把客人领进门(拉客) | accept 这个动作 |
| 张三朝里喊「来客人了,来个服务员」 | accept 返回一个新的 fd |
| 从后厨出来的李四 | 这个新 fd,专门服务这一桌客人 |
| 倒茶、拿菜单、点菜、上菜 | 读写,即 IO 服务 |
| 张三不进包间,继续回门口拉客 | 监听 fd 继续 accept 下一条 |
| 王五、赵六陆续出来 | 每来一个客人,多一个新的 fd |
8.2 两个 fd 的分工
| 监听 fd(张三) | 连接 fd(李四 / 王五 / 赵六) | |
|---|---|---|
| 怎么来的 | socket() + bind() + listen() |
accept() 的返回值 |
| 有几个 | 整个进程只有一个 | 每来一个客户端就多一个 |
| 生命周期 | 与进程同寿 | 服务完这个客户端就 close |
| 干什么 | ★ 只负责获取链接 | ★ 只负责读写 |
| 能不能收数据 | ✖ 永远不用来收发数据 | ✔ 全部业务都在它上面 |

8.3 命名要改
既然职责完全不同,代码里就不该都叫 sockfd。改名:
cpp
int _listensockfd; // 监听套接字,只负责拿连接
accept 返回的那个,在函数里叫 sockfd(或 connfd)------它是给这一个客户端提供服务的。
💡 「监听套接字」这个术语就是这么来的:它只监听,不服务 。把这两者搞混,写出来的代码一定会出问题------比如拿 _listensockfd 去 read,读到的永远是空,因为那条 fd 上根本没有数据流。
九、服务端实现:初始化三步 + 一个服务循环
9.1 类结构
cpp
class TcpServer : public NoCopy
{
public:
TcpServer(uint16_t port) : _port(port), _listensockfd(DefaultSockfd) {}
~TcpServer() {}
void Init(); // 创建 + 绑定 + 监听
void Run(); // 死循环:拿连接 → 服务
private:
void Service(int sockfd); // 处理一个客户端
private:
uint16_t _port;
int _listensockfd;
bool _isrunning;
};
DefaultSockfd 是个常量 -1------fd 的初值统一给 -1 ,这样万一忘了初始化,read(-1, ...) 会立刻报错,而不是误用一个合法 fd。
9.2 Init():三步,每步都是致命错误
cpp
void Init()
{
// ① 创建套接字
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensockfd < 0)
{
LOG(FATAL) << "create socket failed";
exit(static_cast<int>(ExitCode::SOCKET_ERR));
}
LOG(INFO) << "create socket success, sockfd: " << _listensockfd;
// ② 绑定端口
InetAddr local(_port);
if (bind(_listensockfd, local.Addr(), local.Length()) < 0)
{
LOG(FATAL) << "bind failed";
exit(static_cast<int>(ExitCode::BIND_ERR));
}
LOG(INFO) << "bind success";
// ③ 设为监听(TCP 独有)
if (listen(_listensockfd, DefaultBacklog) < 0)
{
LOG(FATAL) << "listen failed";
exit(static_cast<int>(ExitCode::LISTEN_ERR));
}
LOG(INFO) << "listen success";
}
9.3 Run():拿一条、服务一条
cpp
void Run()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
if (sockfd < 0)
{
LOG(WARNING) << "accept failed";
continue; // 非致命,直接下一个
}
InetAddr client(peer);
LOG(INFO) << "get a new link: " << client.Ip() << ":" << client.Port();
Service(sockfd); // ★ 这里是单进程版的死穴所在
}
_isrunning = false;
}
9.4 Service():读 → 判断 → 写回
cpp
void Service(int sockfd)
{
char buffer[1024];
while (true)
{
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = '\0';
LOG(DEBUG) << "client say: " << buffer;
std::string echo = "echo# ";
echo += buffer;
write(sockfd, echo.c_str(), echo.size());
}
else if (n == 0)
{
LOG(INFO) << "client quit";
close(sockfd);
break;
}
else
{
LOG(WARNING) << "read error";
close(sockfd);
break;
}
}
}
💡 这里用 sizeof(buffer) - 1 而不是 sizeof(buffer),是为了给 '\0' 留一个字节 。若恰好读满 1024 字节,buffer[1024] = '\0' 就越界了------这是 C 字符串处理最经典的差一错误。
十、★ read 的三种返回值
TCP 面向字节流,所以读 socket 用的就是读普通文件的接口:
c
ssize_t read(int fd, void *buf, size_t count);
| 返回值 | 含义 | 服务端该做什么 |
|---|---|---|
n > 0 |
读到了 n 个字节 |
正常处理业务 |
n == 0 |
★ 对端把连接关了 | close(sockfd) 后 break |
n < 0 |
读取出错(fd 非法 / 已关闭 / 连接异常) | close(sockfd) 后跳出 |
10.1 n 可能小于期望值
count 是「期望最多读这么多」,不是「一定读到这么多」。
| 概念 | 说明 |
|---|---|
| 期望读 1023 字节 | 传进去的 count |
| 实际读到 1000 字节 | 对端这次只发了这么多,返回值就是 1000 |
| 上限保证 | 返回值一定小于等于 count,不会溢出缓冲区 |
★ 这一条正是「面向字节流」的直接后果:TCP 没有消息边界 ,一次 read 读到多少完全由内核缓冲区里此刻有多少决定。想按「一条消息」处理,得自己在上层做切分。
10.2 n == 0 为什么等于「对端关闭」
这跟管道一模一样:
| 场景 | 读端行为 |
|---|---|
| 管道写端暂时不写 | 读端阻塞等待 |
| 管道写端关闭 | 读端读到 0,即文件结尾 |
| socket 对端暂时不发 | read 阻塞等待 |
socket 对端 close |
read 返回 0 |
★ 所以对服务端来说,read 返回 0 是「客户端退出了」的唯一信号 。收到它就必须把这条连接 fd close 掉------客户端已经关了自己那一端,服务端不关,这个 fd 就一直泄漏着。
十一、客户端实现:socket → connect → write → read → close
11.1 客户端不需要做的事
| 动作 | 要不要 | 原因 |
|---|---|---|
bind(显式) |
✖ 不需要 | 但底层必须绑 ------connect 成功时内核会自动 选一个本地 IP 和随机端口绑上 |
listen |
✖ 不需要 | 那是服务器的活:服务器要设成监听态等着别人来连;客户端是主动去连别人的 |
accept |
✖ 不需要 | 客户端不获取连接,它发起连接 |
★ 「客户端不需要 bind」这句话的准确含义是:不需要显式 bind,但绑定这件事一定会发生,只是交给了内核在 connect 时顺手做掉 。这也解释了为什么 netstat 里能看到客户端用了一个随机的高位端口(如 55880)。
11.2 connect()
c
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
| 参数 | 方向 | 说明 |
|---|---|---|
sockfd |
入 | 自己刚创建的套接字 |
addr |
入 | ★ 目标服务器的地址(不是自己的) |
addrlen |
入 | 该地址结构的长度 |
| 返回值 | --- | 成功 0,失败 -1 |
后两个参数的含义,和 UDP 里 sendto 的后两个参数完全对应 :sendto 是「要把数据发给谁」,connect 是「要把连接发给谁」。
connect 一调用,内核就开始做三次握手。握手完成则返回 0,同时顺手完成本地绑定。
11.3 完整客户端
cpp
void Usage(const char* proc)
{
std::cout << "Usage: " << proc << " server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(static_cast<int>(ExitCode::USAGE_ERR));
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// ① 创建套接字(与服务器完全一样,只差一个枚举)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
LOG(FATAL) << "create socket failed";
exit(static_cast<int>(ExitCode::SOCKET_ERR));
}
// ② 发起连接(不需要 bind,也不需要 listen)
InetAddr server(serverip, serverport);
if (connect(sockfd, server.Addr(), server.Length()) < 0)
{
LOG(FATAL) << "connect failed";
exit(static_cast<int>(ExitCode::CONNECT_ERR));
}
LOG(INFO) << "connect success";
// ③ 发一条、收一条
while (true)
{
std::string line;
std::cout << "Please Enter# ";
std::getline(std::cin, line);
if (line.empty()) continue;
write(sockfd, line.c_str(), line.size());
char buffer[1024];
ssize_t s = read(sockfd, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = '\0';
std::cout << "server echo# " << buffer << std::endl;
}
}
close(sockfd); // 用完了就关,和关普通文件一模一样
return 0;
}
11.4 argc 的校验
| 期望 | argc |
|---|---|
./tcp_client 127.0.0.1 8080 |
3(程序名 + IP + 端口) |
不等于 3 就输出用法提示并退出------服务端只要 2 个(程序名 + 端口),因为它自己就是服务,不需要知道谁来连。
十二、双端完整时序对照

| 步骤 | 服务端 | 客户端 |
|---|---|---|
| 1 | socket() → 拿到 listen fd |
socket() → 拿到自己的 fd |
| 2 | bind() → 绑端口 |
---(不需要显式绑) |
| 3 | ★ listen() → 从此刻起就能被连接 |
---(不需要) |
| 4 | accept() → 阻塞等,直到队列里有货 |
★ connect() → 触发三次握手 |
| 5 | 拿到 connfd,打日志 | 连接成功,可以开始发 |
| 6 | read(connfd) → 阻塞等数据 |
write(fd) → 发出请求 |
| 7 | write(connfd) → 写回响应 |
read(fd) → 阻塞等回声 |
| 8 | close(connfd) → 回到 ④ |
close(fd) → 退出 |
💡 两边节奏完全不耦合:服务端要先跑起来(先 listen),客户端随时可以 connect;客户端发一条,服务端回一条,各自阻塞在自己的读上------这就是「全双工 + 阻塞 IO」最朴素的样子。
十三、实测现象与 netstat 用法
13.1 netstat 常用参数
| 参数 | 含义 |
|---|---|
-n |
显示成数字(127.0.0.1:8080),不做反解 |
-a |
显示所有(含监听态) |
-t |
只看 TCP |
-u |
只看 UDP |
-l |
只看处于 LISTEN 的 |
-p |
显示进程信息(PID / 程序名) |
组合使用:
| 命令 | 用途 |
|---|---|
netstat -nltp |
只看「谁在监听」,最常用 |
netstat -natp |
看所有 TCP,包括已建立的连接 |
13.2 一次完整实测
# 终端 1:起服务
$ ./tcp_server 8080
create socket success, sockfd: 3
bind success
listen success
# 终端 2:起客户端
$ ./tcp_client 127.0.0.1 8080
connect success
Please Enter# 你好
server echo# echo# 你好
服务端同时打出:
get a new link: 127.0.0.1:55880
client say: 你好
55880 就是客户端内核挑的随机端口。再查 netstat -natp,会看到三条记录:一条 LISTEN、两条互为镜像的 ESTABLISHED。
客户端 Ctrl+C 杀掉后:
client quit
服务端读到 0,close(connfd) 退出 Service,回到 accept 等下一位。
十四、★ 单进程版的死穴

Run() 里的 Service(sockfd) 是同步调用 ,而 Service 内部是个 while (true)。于是主线程的执行路径是:
accept() → 拿到 conn1 → Service(conn1) → while(1){ read ... }
↑ 第一个客户不说话,就一直卡在 read
| 现象 | 原因 |
|---|---|
第二个客户端 connect 成功 |
连接由内核建立,与 accept 无关 |
| 但它发消息没有任何回应 | 没人 read 它那条 fd |
| 服务端的日志里也没有它 | 因为根本没走到 accept |
也就是:后面的客户端能被连上,但没人招呼。 用鱼庄的话说,张三拉进第一个客人后跟着进包间端盘子去了,门口没人了;新客人照样能推门进来,但是干站着。
| 这种服务器的定位 | 说明 |
|---|---|
| 只是功能验证版 | 用来确认接口调通了、数据能通 |
| 不可能上线 | 一个客户端连着不说话,整个服务就死了 |
下一节要解决的就是这件事:把「拉客」和「服务」拆到不同的执行流上------多进程、多线程,或者线程池。
十五、速查表
15.1 服务端
| 步骤 | 代码 |
|---|---|
| 创建 | socket(AF_INET, SOCK_STREAM, 0) |
| 绑定 | bind(fd, local.Addr(), local.Length()) |
| ★ 监听 | listen(fd, backlog),backlog 给 8~32 |
| ★ 取连接 | accept(listenfd, (struct sockaddr*)&peer, &len),len = sizeof(peer) |
| 读 | read(connfd, buf, sizeof(buf) - 1) |
| 写 | write(connfd, s.c_str(), s.size()) |
| 关闭 | close(connfd) |
15.2 客户端
| 步骤 | 代码 |
|---|---|
| 创建 | socket(AF_INET, SOCK_STREAM, 0) |
| 绑定 | ✖ 不需要(connect 时内核自动绑) |
| 监听 | ✖ 不需要 |
| ★ 连接 | connect(fd, server.Addr(), server.Length()) |
| 发 / 收 | write / read(与文件相同) |
| 关闭 | close(fd) |
15.3 一次性对照
| 项目 | UDP | TCP |
|---|---|---|
| 套接字类型 | SOCK_DGRAM |
SOCK_STREAM |
| 设监听 | ✖ | ✔ listen |
| 拿连接 | ✖ | ✔ accept(返回新 fd) |
| 主动连接 | ✖ | ✔ connect(客户端) |
| 收发接口 | recvfrom / sendto(带地址) |
read / write(不带地址) |
| fd 数量 | 固定 1 个 | 1 个监听 + N 个连接 |
| 消息边界 | ✔ 有(一个数据报一条) | ✖ 无(字节流) |