一.TCP套接字基础与echo系统
1.什么是TCP套接字
TCP 套接字(TCP Socket)是基于 TCP(传输控制协议)的网络通信接口,用于在网络中实现可靠的、面向连接的双向数据传输。它屏蔽了底层网络细节,让应用程序能通过简单接口进行跨网络通信。它的工作方式如下:
1.1创建套接字socket()
创建套接字对象,返回文件描述符
cpp
int socket(int domain, int type, int protocol);
domain:协议族,TCP 用AF_INET(IPv4)或AF_INET6(IPv6)。type:套接字类型,TCP 用SOCK_STREAM(流式套接字,保证有序、可靠)。protocol:指定协议,TCP 填0(自动匹配SOCK_STREAM对应的 TCP)。
1.2绑定bind()
绑定本地 IP 地址和端口(服务器端必需,客户端可选)
cpp
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:socket()返回的套接字文件描述符。addr:结构体,包含本地 IP 和端口(如struct sockaddr_in用于 IPv4)。
1.3服务端监听listen()
服务器端监听端口,将套接字转为被动模式,等待客户端连接。
cpp
int listen(int sockfd, int backlog);
backlog:最大等待连接队列长度(超过则新连接被拒绝)。- 当一个TCP服务器处于Listen状态,这个服务器就可以被连接(例如用telnet连接,或者用客户端连接).
1.4服务端接收客户端连接accept()
服务器端接收客户端连接,返回新的文件描述符(用于与该客户端通信)。
cpp
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
注意:accept获取现有的连接,并且连接是从内核直接获取的,建立连接的过程与accept无关
对于accept的参数:当接收连接时,我们要知道连接来自哪(sockaddr)以及创建套接字的返回值sockfd。
最重要的是:accept的返回值,如果获取连接成功,该系统调用会返回一个合法整数------它也是一个文件描述符。
问题:为什么TCP在获取连接成功后,还会生成一个新的文件描述符?它和创建socket返回的文件描述符有什么区别?

这就好比:张三是在饭店外拉客的前台人员,当路上有路人经过时他就开始拉客,张三就属于被动连接的套接字;当拉客这一行为(accept)成功时,就创建新的服务员(新套接字)为连接服务。总的来说,监听套接字sockfd是连接入口 ,而accept返回的fd是通信通道。
注意:当accept获取连接失败,不会阻塞或退出,他会立马等待下一个连接。
1.5客户端主动连接服务器connect()
cpp
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
addr:服务器的 IP 和端口。
1.6收发数据**send()/recv() 和 write()/read()**
cpp
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
1.7关闭套接字close()
cpp
int close(int sockfd);
1.8使用过程
-
服务器端步骤:
- 调用
socket()创建套接字。 - 调用
bind()绑定本地 IP 和端口。 - 调用
listen()开始监听。 - 循环调用
accept()接收客户端连接(返回新套接字)。 - 通过新套接字用
recv()/send()与客户端通信。 - 通信结束后用
close()关闭连接。
- 调用
-
客户端步骤:
- 调用
socket()创建套接字。 - 调用
connect()连接服务器的 IP 和端口。 - 通过套接字用
send()/recv()与服务器通信。 - 通信结束后用
close()关闭连接。
- 调用
特点 :TCP 套接字通过三次握手建立连接,保证数据可靠传输(重传、排序、流量控制),适合需要可靠通信的场景(如 HTTP、文件传输)。
1.9三次握手
1. 服务器端准备(触发握手的前提)
服务器需先通过 socket()、bind()、listen() 完成初始化,处于 "监听状态",等待客户端连接:
cpp
// 1. 创建监听套接字(未涉及握手,仅初始化资源)
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定本地 IP 和端口(确定握手的"目标端口")
struct sockaddr_in server_addr;
// 初始化 server_addr(设置 IP、端口等)
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 3. 进入监听状态(关键:此时套接字转为被动模式,允许接收连接请求)
listen(listen_fd, 5); // 第二个参数为等待队列长度
- 此时:服务器的 TCP 协议栈已准备好,可接收客户端的连接请求(三次握手的 "入口" 已打开)。
2. 客户端发起连接(第一次握手)
客户端通过 connect() 接口主动向服务器发起连接,触发第一次握手:
cpp
// 1. 创建客户端套接字
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 初始化服务器地址(目标 IP 和端口)
struct sockaddr_in server_addr;
// 设置 server_addr 为服务器的 IP 和端口
// 3. 发起连接(触发第一次握手)
connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
connect()调用后 :客户端 TCP 协议栈自动向服务器发送 SYN 报文 (第一次握手),包含客户端的初始序列号(seq = x)。- 此时客户端进入
SYN_SENT状态,等待服务器响应。
3. 服务器接收请求并响应(第二次握手)
服务器通过 accept() 接口等待客户端连接,在底层收到 SYN 后自动完成第二次握手:
cpp
// 服务器阻塞等待客户端连接(底层处理握手)
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
accept()阻塞期间 :服务器 TCP 协议栈收到客户端的 SYN 报文后,会自动发送 SYN + ACK 报文 (第二次握手):- 包含服务器的初始序列号(
seq = y); - 包含对客户端 SYN 的确认(
ack = x + 1)。
- 包含服务器的初始序列号(
- 此时服务器进入
SYN_RCVD状态,等待客户端的最终确认。
4. 客户端确认(第三次握手)
客户端收到服务器的 SYN + ACK 后,由 TCP 协议栈自动完成第三次握手,随后 connect() 返回:
- 客户端 TCP 协议栈自动发送 ACK 报文 (第三次握手),包含对服务器 SYN 的确认(
ack = y + 1)。 - 此时客户端进入
ESTABLISHED状态,connect()调用成功返回(不再阻塞)。
5. 服务器完成握手(accept() 返回)
服务器收到客户端的 ACK 报文后:
- 服务器 TCP 协议栈进入
ESTABLISHED状态,完成三次握手。 - 此时
accept()调用成功返回,返回一个新的套接字(client_fd),用于与该客户端通信。
2.多版本的echo系统
2.1服务器端流程(TcpServer.cc)
1. 初始化阶段
cpp
// 创建监听socket
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址
InetAddr local(_port);
bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());
// 开始监听
listen(_listensockfd, backlog);
2. 运行阶段 - 三种并发模型
多进程版本:
cpp
pid_t id = fork(); // 父进程
if(id < 0)
{
LOG(LogLevel::FATAL) << "fork error";
exit(FORK_ERR);
}
else if(id == 0)
{
// 子进程,子进程除了看到sockfd,能看到listensockfd吗??
// 我们不想让子进程访问listensock!
close(_listensockfd);
if(fork() > 0) // 再次fork,子进程退出
exit(OK);
Service(sockfd, addr); // 孙子进程,孤儿进程,1, 系统回收我
exit(OK);
}
else
{
//父进程
close(sockfd);
//父进程是不是要等待子进程啊,要不然僵尸了
pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
(void)rid;
}
进程如果退出,文件默认会被自动释放,fd会自动关闭
该进程的子进程会继承父进程的资源,是可以拿fd进行访问的
换句话说,子进程可以看到父进程的sockfd和listensockfd。
当父子进程并发,子进程执行Service,父进程获取链接,需要各自关闭各自不需要的sockfd。
一个问题:子进程退出,父进程要等待,否则会变成僵尸进程,这样就不是串行了。
我们知道,子进程退出时会向父进程发送一个信号,只需要将这个信号的处理方式改为忽略即可,这样就实现了并发。
第二种解决方式:让子进程fork孙进程,然后自己马上退出。这样父进程就不会被阻塞,而孙进程变成了孤儿进程,会被系统回收。
多线程版本:
两个问题:如果进程打开了一个文件,得到了一个fd,线程可以看到吗?
线程能关闭自己不需要的fd吗?
可以看到fd。但是他不能关闭!因为线程只是新增了pcb,还是共享一个地址空间,关闭fd会影响其他线程。
cpp
ThreadData *td = new ThreadData(sockfd, addr, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
//子进程执行Routine方法
//Routine回调Service
static void *Routine(void *args)
{
pthread_detach(pthread_self());
ThreadData *td = static_cast<ThreadData *>(args);
td->tsvr->Service(td->sockfd, td->addr);
delete td;
return nullptr;
}
线程池版本:
将Service作为任务入队列
cpp
ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){
this->Service(sockfd, addr);
});
3.服务处理
cpp
void Service(int sockfd, InetAddr &peer) {
while (true) {
ssize_t n = read(sockfd, buffer, sizeof(buffer)-1); // 读取请求
std::string echo_string = _func(buffer, peer); // 业务处理
write(sockfd, echo_string.c_str(), echo_string.size()); // 返回响应
}
}
2.2客户端流程(TcpClient.cc)
cpp
// 创建socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 连接服务器
InetAddr serveraddr(serverip, serverport);
connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());
// 通信循环
while (true) {
write(sockfd, line.c_str(), line.size()); // 发送请求
read(sockfd, buffer, sizeof(buffer)-1); // 接收响应
}
2.3设计细节
根据服务器无法拷贝的特性,让TcpServer继承NoCopy,为了达到无法拷贝,无法赋值的目的。
cpp
class NoCopy
{
public:
NoCopy(){}
~NoCopy(){}
NoCopy(const NoCopy &) = delete;
const NoCopy &operator = (const NoCopy&) = delete;
};
二.翻译系统与远程执行命令系统
切换不同的模块,只需要改变服务端的执行方法即可。
1.翻译系统
我们只要将上一章写的Dict.hpp模块导入,然后在服务端做如下改变即可:创建字典对象d,加载字典LoadDict,创建TcpServer对象,并用lambda传参,将消息的处理方法改为字典中的Translate方法。
cpp
Dict d;
d.LoadDict();
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, [&d](const std::string &word, InetAddr &addr){
return d.Translate(word, addr);
});
tsvr->Init();
tsvr->Run();
2.远程执行命令系统
编写远程执行命令的模块:Command.hpp。
按照白名单的方式限制用户的命令。最核心的功能实现在于popen接口:
cpp
FILE *popen(const char *command, const char *mode);
command:字符串,指定要执行的外部命令(如"ls -l"、"grep hello")。mode:字符串,指定管道的方向,只能是"r"或"w":"r":父进程从管道读取子进程的输出(子进程的 stdout 被重定向到管道)。"w":父进程通过管道向子进程写入数据(子进程的 stdin 被重定向到管道)。
简单来说,popen的原理就是一个精简版的myshell:
-
创建管道(pipe) :调用
pipe()系统调用创建一个匿名管道,包含两个文件描述符:fd[0](读端)和fd[1](写端)。管道是内核中的一块缓冲区,用于进程间单向通信。 -
创建子进程(fork) :调用
fork()创建子进程,子进程复制父进程的资源(包括管道的文件描述符)。 -
重定向子进程的 IO :根据
mode参数修改子进程的文件描述符,将其标准输入 / 输出与管道绑定:- 若
mode为"r":子进程关闭管道的读端(fd[0]),将自己的标准输出(stdout,文件描述符 1)通过dup2()重定向到管道的写端(fd[1])。此后,子进程的输出会写入管道。 - 若
mode为"w":子进程关闭管道的写端(fd[1]),将自己的标准输入(stdin,文件描述符 0)通过dup2()重定向到管道的读端(fd[0])。此后,子进程会从管道读取输入。
- 若
-
子进程执行命令(exec) :子进程调用
execl("/bin/sh", "sh", "-c", command, (char*)NULL)执行外部命令:通过 shell 解析command字符串(支持管道、重定向等 shell 语法)。 -
父进程处理管道 :父进程关闭管道中未使用的一端(与子进程相反),并将另一端封装为
FILE*流返回。例如:- 若
mode为"r":父进程关闭写端(fd[1]),用读端(fd[0])创建读流。 - 若
mode为"w":父进程关闭读端(fd[0]),用写端(fd[1])创建写流。
- 若
-
通信与关闭 :父进程通过返回的
FILE*流与子进程通信(读 / 写数据)。完成后需调用pclose()关闭流,pclose()会等待子进程结束并释放资源。
执行命令模块:
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <set>
#include "Command.hpp"
#include "InetAddr.hpp"
#include "Log.hpp"
using namespace LogModule;
class Command
{
public:
// ls -a && rm -rf
// ls -a; rm -rf
Command()
{
// 严格匹配
_WhiteListCommands.insert("ls");
_WhiteListCommands.insert("pwd");
_WhiteListCommands.insert("ls -l");
_WhiteListCommands.insert("touch haha.txt");
_WhiteListCommands.insert("who");
_WhiteListCommands.insert("whoami");
}
bool IsSafeCommand(const std::string &cmd)
{
auto iter = _WhiteListCommands.find(cmd);
return iter != _WhiteListCommands.end();
}
std::string Execute(const std::string &cmd, InetAddr &addr)
{
// 1. 属于白名单命令
if(!IsSafeCommand(cmd))
{
return std::string("坏人");
}
std::string who = addr.StringAddr();
// 2. 执行命令
FILE *fp = popen(cmd.c_str(), "r");
if(nullptr == fp)
{
return std::string("你要执行的命令不存在: ") + cmd;
}
std::string res;
char line[1024];
while(fgets(line, sizeof(line), fp))
{
res += line;
}
pclose(fp);
std::string result = who + "execute done, result is: \n" + res;
LOG(LogLevel::DEBUG) << result;
return result;
}
~Command()
{}
private:
// 受限制的远程执行
std::set<std::string> _WhiteListCommands;
};
TcpServer需要做的改变:
cpp
Command cmd;
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port,
std::bind(&Command::Execute, &cmd, std::placeholders::_1, std::placeholders::_2));