前言
上一篇实现了一个简单的 TCP Echo Server,不过测试的时候发现一个问题:服务器只能正常处理一个客户端。
原因也比较简单,服务器 accept 获取连接后,会直接进入 Service 函数处理当前客户端。由于 Service 内部存在循环,在当前客户端退出之前,服务器不会继续执行 accept。
那么有什么办法可以解决这个问题?
一个比较直接的思路就是:客户端连接成功后,不让服务器主流程一直处理这个客户端,而是把具体的通信工作交给其他执行单元。
这篇主要记录两种实现方式:多进程和多线程。
一、通过多进程处理多个客户端
先看多进程版本。
基本思路是:
- 父进程负责接收客户端连接。
- 每当有客户端连接,就创建一个子进程。
- 子进程负责和客户端通信。
- 父进程继续等待下一个客户端。
这样就不会因为某一个客户端一直占用 Service,而导致服务器无法接收新的连接。
1. 使用fork创建子进程
Linux 中可以通过 fork 创建子进程:
cpp
pid_t id = fork();
if(id < 0)
{
// 创建失败
}
else if(id == 0)
{
// 子进程
}
else
{
// 父进程
}
fork 的返回值需要注意:
- 小于 0:创建进程失败。
- 等于 0:当前执行的是子进程。
- 大于 0:当前执行的是父进程,返回值是子进程的 PID。
在 TCP 服务器中,可以在 accept 成功后调用 fork。
2. 多进程版本的连接处理
核心代码如下:
cpp
void ProcessConnection(
int sockfd,
struct sockaddr_in& peer)
{
pid_t id = fork();
if(id < 0)
{
close(sockfd);
return;
}
else if(id == 0)
{
// child
close(_listensock);
if(fork() > 0)
exit(0);
InetAddr addr(peer);
Service(sockfd);
close(sockfd);
exit(0);
}
else
{
close(sockfd);
waitpid(id, nullptr, 0);
}
}
这里的代码除了第一次 fork,还进行了第二次 fork。
为什么要这样做?
第一次 fork 后,父进程继续负责服务器主流程,子进程负责当前客户端。
子进程关闭监听套接字后再次 fork,原来的子进程退出,真正处理客户端通信的是第二次创建出来的进程,也就是孙子进程。
父进程通过 waitpid 回收第一次创建的子进程,避免产生僵尸进程。
而孙子进程继续执行 Service,处理客户端的请求。
这里需要注意文件描述符的关闭:
- 父进程关闭 accept 返回的通信套接字。
- 子进程关闭监听套接字。
- 孙子进程负责处理通信,并在结束后关闭通信套接字。
这样父进程就能继续执行 accept,等待新的客户端。
3. 为什么需要InetAddr类?
多进程版本中,还引入了一个 InetAddr 类。
它主要用来封装客户端的网络地址信息。
cpp
class InetAddr
{
public:
InetAddr(struct sockaddr_in& addr)
: _addr(addr)
{
_port = ntohs(_addr.sin_port);
_ip = inet_ntoa(_addr.sin_addr);
}
private:
std::string _ip;
uint16_t _port;
struct sockaddr_in _addr;
};
这样就不需要每次都直接操作 sockaddr_in 结构体。
还可以提供一些接口:
cpp
Ip();
Port();
PrintDebug();
GetAddr();
例如将客户端地址打印出来:
text
127.0.0.1:4444
这样在服务器处理多个客户端时,就比较方便区分不同的连接。
二、通过多线程处理多个客户端
多进程可以解决问题,不过这里还可以换一种方式:使用多线程。
基本思路和多进程类似:
- 主线程负责 accept。
- 每次获取一个客户端连接,就创建一个线程。
- 新线程负责处理当前客户端。
- 主线程继续接收其他客户端。
这样同样可以实现多个客户端同时连接。
1. 使用ThreadData保存线程数据
线程启动时,需要知道自己应该处理哪个客户端。
所以这里定义了一个 ThreadData 类:
cpp
class ThreadData
{
public:
ThreadData(
int sockfd,
struct sockaddr_in addr)
: _sockfd(sockfd), _addr(addr)
{}
public:
int _sockfd;
InetAddr _addr;
};
这里保存了两个信息:
_sockfd:当前客户端对应的通信套接字。_addr:当前客户端的网络地址。
这样每个线程都能拿到自己需要的数据。
2. 将Service交给线程执行
原来的 Service 函数主要负责读取客户端数据并返回 Echo 消息。
多线程版本中,将它改成静态成员函数:
cpp
static void Service(ThreadData& td)
{
char buffer[1024];
while(true)
{
ssize_t n = read(
td._sockfd,
buffer,
sizeof(buffer) - 1
);
if(n > 0)
{
buffer[n] = '\0';
string echo_string = "server echo# ";
echo_string += buffer;
write(
td._sockfd,
echo_string.c_str(),
echo_string.size()
);
}
else if(n == 0)
{
break;
}
else
{
break;
}
}
}
这里使用静态函数,主要是为了方便通过 pthread 的线程入口函数调用。
3. 编写线程入口函数
pthread_create 要求线程入口函数具有规定的函数形式,因此需要再封装一个函数:
cpp
static void* threadExcute(void* args)
{
pthread_detach(pthread_self());
ThreadData* td =
static_cast<ThreadData*>(args);
TcpServer::Service(*td);
close(td->_sockfd);
delete td;
return nullptr;
}
这里有几个地方需要注意。
第一,pthread_detach。
线程默认是可连接的,使用 detach 后,线程运行结束时,系统会自动回收对应的线程资源,不需要再通过 pthread_join 等待它。
第二,ThreadData 的生命周期。
这里通过 new 创建 ThreadData:
cpp
ThreadData* td = new ThreadData(sockfd, peer);
因为线程是独立执行的,如果直接把一个局部对象的地址传进去,就可能在线程使用之前,对象已经销毁。
所以这里动态申请空间,并在线程处理结束后通过 delete 释放。
第三,关闭文件描述符。
线程处理完成后,需要关闭对应的通信套接字。
4. 创建线程处理客户端连接
有了线程入口函数之后,就可以在 ProcessConnection 中创建线程:
cpp
void ProcessConnection(
int sockfd,
struct sockaddr_in& peer)
{
pthread_t tid;
ThreadData* td =
new ThreadData(sockfd, peer);
pthread_create(
&tid,
nullptr,
threadExcute,
(void*)td
);
}
主线程只需要调用 ProcessConnection,就能将当前客户端交给新线程处理。
而主线程本身不需要等待这个线程执行完毕,可以继续 accept 下一个客户端。
三、多进程和多线程的整体流程
把两种方式放在一起看,思路其实很相似。
多进程版本:
text
主进程
|
| accept
|
+---- 客户端1 -> 子进程处理
|
+---- 客户端2 -> 子进程处理
|
+---- 客户端3 -> 子进程处理
多线程版本:
text
主线程
|
| accept
|
+---- 客户端1 -> 线程1处理
|
+---- 客户端2 -> 线程2处理
|
+---- 客户端3 -> 线程3处理
两种方式都是将连接接收和业务处理分开。
不过,这里的多线程版本仍然是一个客户端创建一个线程。后面还可以继续使用线程池改进这种设计。
四、总结
这篇主要解决了 TCP Echo Server 只能处理一个客户端的问题。
多进程版本使用 fork 创建子进程,通过父子进程分工,让父进程继续接收新的连接。
多线程版本则使用 pthread_create 创建线程,将每个客户端的通信任务交给独立线程处理。
其中几个知识点需要重点理解:
fork():创建子进程。waitpid():等待并回收子进程。pthread_create():创建线程。pthread_detach():分离线程。ThreadData:保存每个客户端对应的通信信息。InetAddr:封装客户端的 IP 和端口信息。
从代码结构上看,这次改动并不算特别大,主要变化就是把原本由服务器主流程执行的 Service 交给了其他进程或线程。
下一篇继续在多线程版本的基础上增加业务功能,并尝试使用线程池处理客户端请求。