上一篇我们已经完整实现了 UDP Socket 编程。
UDP Server 的核心流程比较简单:
text
socket()
↓
bind()
↓
recvfrom()
↓
业务处理
↓
sendto()
但 TCP 和 UDP 有一个非常关键的区别:
TCP 是面向连接的。
因此 TCP 在正式通信之前,必须先建立连接。
服务器和客户端的流程也就变成了:
text
TCP Server
socket()
↓
bind()
↓
listen()
↓
accept()
↓
read() / write()
text
TCP Client
socket()
↓
connect()
↓
read() / write()
这一篇就沿着这两条主线真正写出一个 TCP Server,并在此基础上一步步解决:
text
单客户端
↓
多客户端
↓
多进程
↓
多线程
↓
线程池
↓
守护进程
最后再回到 TCP 本身,从内核角度理解:
text
连接到底是什么?
accept 和三次握手是什么关系?
为什么 TCP 是全双工?
read/write 到底在干什么?
为什么 TCP 必须解决消息边界问题即粘包问题?
1. 先把最基本的 TCP 通信跑起来
1.1 程序结构
和 UDP 版本一样,我们先建立几个最基本的文件:
text
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile
最终实现的仍然是最简单的 Echo Server:
text
Client
↓
发送 hello
↓
Server
↓
收到 hello
↓
原样返回 hello
↓
Client
先准备 Makefile:
makefile
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf server client
1.2 Server:socket 和 bind
首先创建服务器 Socket。
UDP 中:
cpp
socket(AF_INET, SOCK_DGRAM, 0);
TCP 中:
cpp
socket(AF_INET, SOCK_STREAM, 0);
区别就在第二个参数:
text
SOCK_DGRAM
↓
UDP 数据报
SOCK_STREAM
↓
TCP 字节流
因此:
cpp
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
创建的是一个 TCP Socket。
接着仍然需要绑定服务器自己的:
text
IP
+
Port
cpp
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);
bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local));
这里和 UDP Server 基本一致。
端口仍然需要:
cpp
htons();
转换为网络字节序。
到这里:
text
UDP Server
已经可以直接等待数据了。
但 TCP 不行。
因为 TCP 是:
面向连接的。
服务器还必须告诉操作系统:
我这个 Socket 接下来不是主动连接别人,而是专门等待别人来连接我。
这就是:
cpp
listen();
1.3 listen:把 Socket 变成监听 Socket
函数原型:
cpp
#include <sys/socket.h>
int listen(int sockfd, int backlog);
使用:
cpp
listen(listen_sock_, backlog);
第一个参数:
cpp
sockfd
就是之前:
cpp
socket();
创建并通过:
cpp
bind();
绑定好的 Socket。
listen() 做完以后,这个 Socket 就从一个普通 TCP Socket 变成了:
监听 Socket。
它以后主要负责:
text
等待连接
+
获取连接
而不是直接和客户端收发业务数据。
backlog 到底是什么?
我们这一篇暂时不用继续深入,只需要记住:
text
backlog
↓
等待 accept 的连接队列长度
另外:
cpp
listen();
本身通常不会阻塞等待客户端。
真正负责:
没有连接就一直等。
的是下面的:
cpp
accept();
1.4 accept:真正获取一个连接
函数原型:
cpp
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
使用:
cpp
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
在默认阻塞模式下,如果当前没有已经建立好的连接:
text
accept()
↓
阻塞等待
一旦有客户端成功建立 TCP 连接:
text
accept()
↓
返回
但这里最重要的不是阻塞。
而是它的返回值:
cpp
service_sock
为什么服务器需要两个 Socket?
这是 TCP Socket 编程中最重要的地方之一。
一开始:
cpp
listen_sock_ = socket(...);
得到的是:
text
监听 Socket
它负责:
text
listen
accept
但是:
cpp
accept();
成功以后会返回:
cpp
service_sock
这个才是:
专门和当前客户端通信的 Socket。
所以服务器实际上是:
text
┌── service_sock A ↔ Client A
│
listen_sock ─accept─┼── service_sock B ↔ Client B
│
└── service_sock C ↔ Client C
假设有三个客户端连接服务器。
服务器一共会存在:
text
1 个 listen_sock
+
3 个 service_sock
监听 Socket 继续等待新连接。
每一个 accept() 返回的 Socket,则负责和对应客户端通信。
所以这两个 Socket 的职责一定不能混:
text
listen_sock
↓
只负责获取新连接
service_sock
↓
真正负责 read/write
accept 还能拿到客户端信息
和 UDP 的:
cpp
recvfrom();
类似,accept() 的:
cpp
addr
addrlen
也能够把客户端地址带回来。
例如:
cpp
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
于是服务器能够知道:
text
哪个 IP
+
哪个 Port
建立了连接。
1.5 当前阶段完整 Server
到这里,我们已经把 TCP Server 建立连接之前需要的几个核心接口学完了:
text
socket()
↓
创建 TCP Socket
↓
bind()
↓
绑定服务器 IP + Port
↓
listen()
↓
进入监听状态
↓
accept()
↓
获取已经建立好的 TCP 连接
此时这个 Server 暂时还不会和客户端进行数据通信,它只完成一件事:
等待客户端建立连接,并打印客户端的 IP、Port 以及
accept()返回的通信 Socket。
当前项目只有三个文件:
text
TcpServer
├── server.hpp
├── server.cc
└── Makefile
server.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace fxs_server
{
const uint16_t default_port = 8877;
const std::string default_ip = "0.0.0.0";
const int backlog = 5;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
class TcpServer
{
public:
TcpServer(uint16_t port = default_port, const std::string& ip = default_ip)
: ip_(ip), port_(port)
{}
~TcpServer()
{}
void InitServer()
{
// 1.创建TCP监听套接字
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock_ < 0)
{
std::cerr << "Create Socket Fail: "
<< strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success: "
<< listen_sock_ << std::endl;
// 2.绑定IP和Port
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "Bind IP&&Port Fail: "
<< strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "Bind IP&&Port Success" << std::endl;
// 3.进入监听状态
if(listen(listen_sock_, backlog) < 0)
{
std::cerr << "Listen Fail: "
<< strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "Listen Success" << std::endl;
}
void StartServer()
{
while(true)
{
// 4.获取已经建立好的连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
{
std::cerr << "Accept Fail: "
<< strerror(errno) << std::endl;
continue;
}
// 5.获取客户端IP和Port
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
uint16_t clientport = ntohs(client.sin_port);
std::cout << "Get a new connection -> "
<< "[" << clientip << ":" << clientport << "] "
<< "service_sock: " << service_sock
<< std::endl;
// 当前阶段还没有学习数据通信,
// 所以这里只验证accept能够成功获取连接
close(service_sock);
}
}
private:
int listen_sock_;
std::string ip_;
uint16_t port_;
};
}
server.cc
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
unique_ptr<TcpServer> server(new TcpServer(port));
server->InitServer();
server->StartServer();
return 0;
}
编译:
bash
make
运行:
bash
./server 8877
此时可以看到:
text
Create Socket Success: ...
Bind IP&&Port Success
Listen Success
随后程序停在那里。
这并不是程序卡死了,而是:
cpp
accept();
在默认阻塞模式下发现当前还没有已经建立好的连接,因此正在等待客户端。
现在,我们就可以真正找一个 TCP 客户端来验证它。
1.6 使用 telnet 测试 TCP 连接
目前我们还没有编写自己的 TCP Client。
但是为了验证:
text
socket
bind
listen
accept
这一整套流程是否已经正常工作,可以暂时使用:
text
telnet
充当 TCP Client。
如果 Ubuntu 中还没有安装:
bash
sudo apt install telnet
首先保持 Server 正在运行:
bash
./server 8877
然后再打开一个终端:
bash
telnet 127.0.0.1 8877
此时客户端会主动向:
text
127.0.0.1:8877
建立 TCP 连接。
Server 中原本阻塞的:
cpp
accept();
就会立即返回,并打印类似:
text
Get a new connection -> [127.0.0.1:xxxxx] service_sock: 4
其中:
text
127.0.0.1
↓
客户端IP
xxxxx
↓
客户端由操作系统自动分配的临时Port
4
↓
accept返回的通信Socket文件描述符
整个过程:
text
Server
↓
socket()
↓
bind()
↓
listen()
↓
accept()
↓
等待......
Client
↓
telnet 127.0.0.1 8877
↓
TCP建立连接
↓
Server accept()返回
↓
获得client IP + Port
↓
获得service_sock

这里 telnet 建立连接以后很快又会被 Server 断开,这是正常现象。
因为当前代码:
cpp
accept();
成功以后只做了:
cpp
打印连接信息
↓
close(service_sock)
我们目前只是为了验证:
TCP Server 已经能够成功监听并获取连接。
下一步学习:
cpp
read();
write();
以后,服务器才会真正利用这个 service_sock 和客户端进行数据通信。
1.7 read 和 write:真正开始 TCP 通信
连接建立完成以后,接下来就是通信。
Linux 中:
Socket 也是文件。
所以我们完全可以直接使用以前学习文件时的:
cpp
read();
write();
读取和发送网络数据。
服务器业务函数:
cpp
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
char buff[1024];
std::string who = clientip + "-" + std::to_string(clientport);
while(true)
{
ssize_t n = read(sock, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Server get: "
<< buff
<< " from "
<< who
<< std::endl;
std::string respond = func_(buff);
write(sock, respond.c_str(), respond.size());
}
else if(n == 0)
{
std::cout << "Client "
<< who
<< " quit!"
<< std::endl;
close(sock);
break;
}
else
{
std::cerr << "Read Fail! "
<< strerror(errno)
<< std::endl;
close(sock);
break;
}
}
}
这里:
cpp
read();
一共有三种结果。
read > 0
表示:
text
真正读取到了数据
返回值:
cpp
n
就是实际读取的字节数。
因此:
cpp
buff[n] = '\0';
把它临时当 C 风格字符串使用。
read == 0
这个特别重要。
对于 TCP Socket:
cpp
read() == 0
不是:
对方给我发送了一个长度为 0 的普通消息。
而意味着:
TCP 字节流已经到达 EOF,通常说明对端已经有序关闭了这一方向的连接。
最常见的情况就是:
text
客户端 close()
↓
TCP 通知服务器
↓
服务器 read()
↓
返回 0
于是服务器知道:
客户端退出了。
这也是 TCP 和 UDP 很明显的一个区别。
UDP 没有连接状态,所以对端程序退出时:
text
Server
并不会自动知道。
TCP 因为维护连接,所以:
text
read() == 0
可以让我们感知对端关闭。
read < 0
表示:
text
读取发生错误
此时:
cpp
errno
保存错误原因。
1.8 Client:socket 和 connect
TCP Client 初始化仍然先:
cpp
sock_ = socket(AF_INET, SOCK_STREAM, 0);
客户端一般不需要像 Server 一样手动:
cpp
bind();
因为:
客户端只要主动发起连接,操作系统就可以自动给它选择合适的本地 IP 和临时端口。
所以客户端真正新增的核心接口是:
cpp
connect();
函数原型:
cpp
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
使用:
cpp
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_aton(server_ip_.c_str(), &server.sin_addr);
server.sin_port = htons(server_port_);
connect(sock_, (const struct sockaddr*)&server, sizeof(server));
这里:
cpp
bind()
传的是:
text
自己的地址
而:
cpp
connect()
传的是:
text
对方 Server 的地址
connect 成功到底意味着什么?
这个地方必须说清楚。
调用:
cpp
connect();
以后,真正负责建立 TCP 连接的不是我们的 C++ 代码。
而是:
客户端和服务器两边操作系统中的 TCP 协议栈。
connect() 会触发 TCP 建连流程。
当三次握手成功以后:
cpp
connect();
就可以成功返回。
这里并不要求服务器应用层已经执行:
cpp
accept();
因为:
text
三次握手
↓
TCP 内核协议栈完成
accept()
↓
应用层从内核已经完成的连接中取走一个
这是两层完全不同的事情。
所以以后一定要记住:
connect 对应"主动发起连接",accept 对应"拿走已经建立好的连接结果",accept 并不负责完成三次握手。
连接失败重试
服务器可能暂时没有启动。
如果:
cpp
connect();
失败就立即退出,体验并不好。
所以可以简单尝试数次:
cpp
int cnt = 5;
while(cnt)
{
int ret = connect(sock_, (const struct sockaddr*)&server, sizeof(server));
if(ret == 0)
break;
std::cerr << "网络异常,正在进行重连... 剩余连接次数: "
<< --cnt
<< std::endl;
sleep(1);
}
if(cnt == 0)
{
std::cerr << "连接失败! "
<< strerror(errno)
<< std::endl;
close(sock_);
exit(CONNECT_ERR);
}
1.9 Client 获取服务
连接成功后:
text
Client
同样使用:
cpp
write();
read();
通信。
cpp
void GetService()
{
char buff[1024];
std::string who = server_ip_ + "-" + std::to_string(server_port_);
while(true)
{
std::string msg;
std::cout << "Please Enter >> ";
std::getline(std::cin, msg);
write(sock_, msg.c_str(), msg.size());
ssize_t n = read(sock_, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Client get: "
<< buff
<< " from "
<< who
<< std::endl;
}
else if(n == 0)
{
std::cout << "Server "
<< who
<< " quit!"
<< std::endl;
close(sock_);
break;
}
else
{
std::cerr << "Read Fail! "
<< strerror(errno)
<< std::endl;
close(sock_);
break;
}
}
}
于是一个完整请求:
text
Client getline()
↓
write()
↓
Server read()
↓
业务处理
↓
Server write()
↓
Client read()
基本 TCP Echo 就打通了。
1.10 基础版完整代码
server.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace fxs_server
{
const uint16_t default_port = 8877;
const std::string default_ip = "0.0.0.0";
const int backlog = 5;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
using func_t = std::function<std::string(std::string)>;
class TcpServer
{
public:
TcpServer(const func_t& func, const uint16_t port = default_port, const std::string ip = default_ip)
: ip_(ip), port_(port), func_(func)
{}
~TcpServer()
{}
void InitServer()
{
// 1.创建监听套接字
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock_ == -1)
{
std::cerr << "Create Socket Fail! "
<< strerror(errno)
<< std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success! "
<< listen_sock_
<< std::endl;
// 2.绑定IP和Port
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(ip_.c_str());
local.sin_port = htons(port_);
if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "Bind IP&&Port Fail: "
<< strerror(errno)
<< std::endl;
exit(BIND_ERR);
}
// 3.进入监听状态
if(listen(listen_sock_, backlog) < 0)
{
std::cerr << "Listen Fail: "
<< strerror(errno)
<< std::endl;
exit(LISTEN_ERR);
}
}
void StartServer()
{
while(true)
{
// 1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
{
std::cerr << "Accept Fail: "
<< strerror(errno)
<< std::endl;
continue;
}
// 2.获得客户端信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
std::cout << "Server accept "
<< clientip
<< ":"
<< clientport
<< ", service sock: "
<< service_sock
<< std::endl;
// 3.提供服务
Service(service_sock, clientip, clientport);
}
}
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
char buff[1024];
std::string who = clientip + "-" + std::to_string(clientport);
while(true)
{
ssize_t n = read(sock, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Server get: "
<< buff
<< " from "
<< who
<< std::endl;
std::string respond = func_(buff);
write(sock, respond.c_str(), respond.size());
}
else if(n == 0)
{
std::cout << "Client "
<< who
<< " quit!"
<< std::endl;
close(sock);
break;
}
else
{
std::cerr << "Read Fail! "
<< strerror(errno)
<< std::endl;
close(sock);
break;
}
}
}
private:
int listen_sock_;
std::string ip_;
uint16_t port_;
func_t func_;
};
}
server.cc
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
client.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace fxs_client
{
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
CONNECT_ERR
};
class TcpClient
{
public:
TcpClient(const std::string& ip, const uint16_t port)
: server_ip_(ip), server_port_(port)
{}
~TcpClient()
{}
void InitClient()
{
sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(sock_ == -1)
{
std::cerr << "Create Socket Fail! "
<< strerror(errno)
<< std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success! "
<< sock_
<< std::endl;
}
void StartClient()
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_aton(server_ip_.c_str(), &server.sin_addr);
server.sin_port = htons(server_port_);
int cnt = 5;
while(cnt)
{
int ret = connect(sock_, (const struct sockaddr*)&server, sizeof(server));
if(ret == 0)
break;
std::cerr << "网络异常,正在进行重连... 剩余连接次数: "
<< --cnt
<< std::endl;
sleep(1);
}
if(cnt == 0)
{
std::cerr << "连接失败! "
<< strerror(errno)
<< std::endl;
close(sock_);
exit(CONNECT_ERR);
}
std::cout << "连接成功!" << std::endl;
GetService();
}
void GetService()
{
char buff[1024];
std::string who = server_ip_ + "-" + std::to_string(server_port_);
while(true)
{
std::string msg;
std::cout << "Please Enter >> ";
std::getline(std::cin, msg);
write(sock_, msg.c_str(), msg.size());
ssize_t n = read(sock_, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Client get: "
<< buff
<< " from "
<< who
<< std::endl;
}
else if(n == 0)
{
std::cout << "Server "
<< who
<< " quit!"
<< std::endl;
close(sock_);
break;
}
else
{
std::cerr << "Read Fail! "
<< strerror(errno)
<< std::endl;
close(sock_);
break;
}
}
}
private:
int sock_;
std::string server_ip_;
uint16_t server_port_;
};
}
client.cc
cpp
#include <iostream>
#include <memory>
#include "client.hpp"
using namespace std;
using namespace fxs_client;
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerIP ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return USAGE_ERR;
}
std::string ip = argv[1];
uint16_t port = stoi(argv[2]);
unique_ptr<TcpClient> client(new TcpClient(ip, port));
client->InitClient();
client->StartClient();
return 0;
}
1.9 运行测试
编译:
bash
make
启动服务器:
bash
./server 8877
启动客户端:
bash
./client 127.0.0.1 8877
客户端输入:
text
hello tcp
应该得到:
text
Client
↓
hello tcp
↓
Server
↓
hello tcp
↓
Client

还可以查看当前 TCP 监听端口:
bash
ss -ntlp | grep 8877

bash
netsat -nltp | grep 8877

2. 单进程服务器为什么不能同时服务多个客户端?
现在打开两个 Client:
text
Client A
Client B
A 先连接。
Server 执行:
cpp
accept();
获得:
text
A 的 service_sock
然后马上进入:
cpp
Service();
而:
cpp
Service();
内部是:
cpp
while(true)
不断为 A 服务。
因此服务器此时的执行流:
text
accept A
↓
Service A
↓
Service A
↓
Service A
↓
......
根本回不到下一次:
cpp
accept();
所以 B 无法被应用层处理。
为什么 B 的 connect 却可能显示成功?
这个地方特别容易产生误解。
B:
cpp
connect();
成功,只说明:
客户端和服务器内核中的 TCP 三次握手成功了。
并不代表:
服务器应用代码已经调用 accept() 并开始处理 B。
因此完全可能出现:
text
Client B
↓
connect 成功
Server 应用
↓
还在 Service(A)
B 对应的连接已经在:
text
内核
中建立完成,并等待:
cpp
accept();
取走。
所以:
text
connect 成功
≠
服务器业务代码已经开始服务你
这正好也说明:
连接建立属于内核 TCP 协议栈,而 accept 属于应用层获取连接。
问题真正出在:
text
接受新连接
和:
text
为旧连接提供服务
使用了同一个执行流。
所以接下来我们必须:
让"获取连接"和"处理连接"同时进行。
第一种方法:
text
多进程
3. 多进程版 TCP Server
核心思路:
text
父进程
↓
只负责 accept
子进程
↓
负责 Service
每建立一个新连接:
text
accept()
↓
fork()
↓
┌───────────────┐
│ │
父进程 子进程
│ │
继续 accept Service
3.1 为什么子进程能够直接使用 service_sock?
因为:
cpp
fork();
以后,子进程会继承父进程的:
文件描述符表。
所以父进程刚刚:
cpp
accept();
得到的:
text
service_sock
子进程同样能够访问。
假设:
text
父进程
fd 3 -> listen socket
fd 4 -> client socket
fork() 后:
text
父进程 子进程
fd 3 -> listen fd 3 -> listen
fd 4 -> client fd 4 -> client
但两边职责不同。
子进程:
text
只负责当前 Client
所以不需要:
text
listen_sock
应该:
cpp
close(listen_sock_);
父进程:
text
只负责继续 accept
所以不需要这个客户端的:
text
service_sock
应该:
cpp
close(service_sock);
注意:
这里父进程
close(service_sock)并不会立即断开子进程和客户端的连接。
因为 fork() 后父子进程的文件描述符虽然是两份表项,但都引用内核中的同一个打开文件对象。
只有相关引用全部关闭,底层连接才真正失去引用。
3.2 最直接的多进程版本
cpp
void StartServer()
{
while(true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
continue;
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
pid_t id = fork();
if(id < 0)
{
close(service_sock);
continue;
}
else if(id == 0)
{
close(listen_sock_);
Service(service_sock, clientip, clientport);
exit(0);
}
close(service_sock);
}
}
这样:
text
Client A -> Child A
Client B -> Child B
Client C -> Child C
而父进程始终:
text
accept
accept
accept
服务器就具备并发能力了。
3.3 子进程退出后谁来回收?
这里又出现进程编程中的老问题:
僵尸进程。
子进程完成:
cpp
Service();
以后退出。
父进程如果完全不:
cpp
wait();
waitpid();
就可能产生僵尸进程。
那是不是直接:
cpp
waitpid(id, nullptr, 0);
就行?
也不行。
因为这是:
text
阻塞等待
父进程就会变成:
text
fork Client A
↓
wait Client A
↓
Client A 不退出
↓
父进程无法 accept B
又失去了并发意义。
所以我们需要:
既回收子进程,又不能阻塞父进程继续 accept。
3.4 四种处理思路
方法一:waitpid + WNOHANG
cpp
waitpid(id, nullptr, WNOHANG);
这样:
text
waitpid
本身不会阻塞。
但问题是:
如果调用
waitpid()时子进程还没退出,它会直接返回。
以后子进程真正退出时,如果父进程正阻塞在:
cpp
accept();
就可能暂时没人回收。
所以只是简单在:
cpp
fork();
后调用一次:
cpp
waitpid(..., WNOHANG);
并不能彻底解决问题。
方法二:捕获 SIGCHLD
子进程退出时,父进程会收到:
text
SIGCHLD
所以可以注册信号处理函数:
cpp
static void Handler(int signo)
{
while(waitpid(-1, nullptr, WNOHANG) > 0)
{}
}
然后:
cpp
signal(SIGCHLD, Handler);
这样:
text
父进程
↓
正常 accept
子进程退出
↓
SIGCHLD
↓
Handler
↓
waitpid
就能做到异步回收。
注意:
信号处理函数中应该尽量只调用异步信号安全的接口,不要随便塞大量
cout、内存分配等复杂逻辑。
方法三:显式忽略 SIGCHLD
Linux 下还有一种非常简单的方法:
cpp
signal(SIGCHLD, SIG_IGN);
显式将:
text
SIGCHLD
设置为忽略以后,Linux 可以自动处理这些退出子进程,不让它们长期处于僵尸状态。
对于我们现在这个简单 TCP Server:
cpp
signal(SIGCHLD, SIG_IGN);
是最省事的写法。
因此最终:
cpp
void StartServer()
{
signal(SIGCHLD, SIG_IGN);
while(true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
continue;
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
pid_t id = fork();
if(id < 0)
{
close(service_sock);
continue;
}
else if(id == 0)
{
close(listen_sock_);
Service(service_sock, clientip, clientport);
exit(0);
}
close(service_sock);
}
}
方法四:双重 fork
还有一种经典技巧:
text
父进程
↓
fork 子进程
↓
子进程再 fork 孙子进程
↓
子进程立即退出
↓
父进程只等待这个很快退出的子进程
↓
孙子进程真正 Service
原来的父子关系被切断以后,孙子进程会由系统中的父进程接管机制负责。
这种方式能够绕开父进程长期等待业务进程的问题。
不过:
对我们当前 TCP Server 来说,没有必要专门绕这么一大圈。
知道这个方法即可。
后面讲守护进程时,还会再次看到:
text
fork
+
setsid
+
再次 fork
这种思路。
3.5 多进程版 TCP Server 完整代码
前面我们已经把多进程服务器中几个关键问题解决了:
text
父进程
↓
accept()
↓
获取新连接
↓
fork()
父进程
↓
关闭自己的 service_sock
↓
继续 accept()
子进程
↓
关闭 listen_sock
↓
Service()
↓
专门服务当前客户端
同时,子进程退出以后还需要解决:
text
僵尸进程
的问题。
在当前这个简单 TCP Server 中,我们采用:
cpp
signal(SIGCHLD, SIG_IGN);
让 Linux 自动处理退出的子进程。
因此,现在可以把前面的内容真正组合起来,得到一份完整的多进程 TCP Server。
当前代码结构:
text
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile
客户端本身不需要发生变化,仍然使用前面已经写好的 TCP Client。
这一阶段真正修改的是 Server。
server.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace fxs_server
{
const uint16_t default_port = 8877;
const std::string default_ip = "0.0.0.0";
const int backlog = 5;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
using func_t = std::function<std::string(std::string)>;
class TcpServer
{
public:
TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
: ip_(ip), port_(port), func_(func)
{}
~TcpServer()
{}
void InitServer()
{
// 1.创建监听套接字
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock_ < 0)
{
std::cerr << "Create Socket Fail: "
<< strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success: "
<< listen_sock_ << std::endl;
// 2.绑定IP和Port
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "Bind IP&&Port Fail: "
<< strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "Bind IP&&Port Success" << std::endl;
// 3.进入监听状态
if(listen(listen_sock_, backlog) < 0)
{
std::cerr << "Listen Fail: "
<< strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "Listen Success" << std::endl;
}
void Service(int sock, const std::string& clientip, uint16_t clientport)
{
char buff[1024];
std::string who = clientip + ":" + std::to_string(clientport);
while(true)
{
ssize_t n = read(sock, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Server get: "
<< buff
<< " from "
<< who
<< std::endl;
std::string respond = func_(buff);
write(sock, respond.c_str(), respond.size());
}
else if(n == 0)
{
std::cout << "Client "
<< who
<< " quit!"
<< std::endl;
close(sock);
break;
}
else
{
std::cerr << "Read Fail: "
<< strerror(errno)
<< std::endl;
close(sock);
break;
}
}
}
void StartServer()
{
// 子进程退出后由系统自动处理,避免产生僵尸进程
signal(SIGCHLD, SIG_IGN);
while(true)
{
// 1.父进程获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
{
std::cerr << "Accept Fail: "
<< strerror(errno)
<< std::endl;
continue;
}
// 2.获取客户端信息
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
uint16_t clientport = ntohs(client.sin_port);
std::cout << "Get a new connection -> "
<< "[" << clientip << ":" << clientport << "] "
<< "service_sock: " << service_sock
<< std::endl;
// 3.创建子进程
pid_t id = fork();
if(id < 0)
{
std::cerr << "Fork Fail: "
<< strerror(errno)
<< std::endl;
close(service_sock);
continue;
}
else if(id == 0)
{
// 子进程只负责当前客户端,不需要监听套接字
close(listen_sock_);
Service(service_sock, clientip, clientport);
exit(0);
}
// 父进程只负责继续获取新连接
close(service_sock);
}
}
private:
int listen_sock_;
std::string ip_;
uint16_t port_;
func_t func_;
};
}
这里最核心的其实就是:
cpp
pid_t id = fork();
之后父子进程职责彻底分开:
text
父进程
↓
close(service_sock)
↓
回到 accept()
↓
继续等待其他客户端
子进程
↓
close(listen_sock_)
↓
Service(service_sock)
↓
一直服务当前客户端
所以此时:
text
Client A
↓
Child Process A
Client B
↓
Child Process B
Client C
↓
Child Process C
父进程则始终专门负责:
cpp
accept();
这就解决了前面单进程版本:
text
Service(A)
↓
长期不返回
↓
无法继续 accept(B)
的问题。
server.cc
业务层仍然使用最简单的 Echo:
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
可以看到,业务层完全没有因为服务器从:
text
单进程
改成:
text
多进程
而发生变化。
变化的只是:
服务器如何调度不同客户端。
运行多进程 Server
重新编译:
bash
make clean
make
启动服务器:
bash
./server 8877
然后分别打开多个终端。
Client A:
bash
./client 127.0.0.1 8877
Client B:
bash
./client 127.0.0.1 8877
Client C:
bash
./client 127.0.0.1 8877
此时三个客户端都可以持续和服务器通信。
例如:
text
Client A
Please Enter >> hello A
Client B
Please Enter >> hello B
Client C
Please Enter >> hello C
三个客户端都能分别收到自己的 Echo。
这说明:
text
父进程
↓
仍然可以不断 accept()
多个子进程
↓
同时为不同客户端提供 Service()

查看 Server 的多个进程
此时再打开一个终端:
bash
ps -axj | head -1 && ps -axj | grep 8877 | grep -v grep
观察父子关系
应该可以看到:
text
一个 Server 父进程
+
多个 Server 子进程

再观察一次文件描述符关系
这个版本还有一个特别值得复习的地方。
假设父进程:
text
listen_sock = 3
service_sock = 4
执行:
cpp
fork();
之后:
text
父进程 子进程
fd 3 fd 3
↓ ↓
listen Socket listen Socket
fd 4 fd 4
↓ ↓
Client Socket Client Socket
因为子进程继承了父进程的文件描述符表。
但是双方职责不同。
所以子进程:
cpp
close(listen_sock_);
只保留:
text
service_sock
用于服务客户端。
父进程:
cpp
close(service_sock);
只保留:
text
listen_sock
继续接受新连接。
最终:
text
Parent
│
└── listen_sock
│
├── accept A -> fork -> Child A -> service_sock A
│
├── accept B -> fork -> Child B -> service_sock B
│
└── accept C -> fork -> Child C -> service_sock C
这就是当前多进程 TCP Server 的完整结构。
3.6 多进程版本还有什么问题?
现在服务器确实已经能够同时服务多个客户端了。
但是它采用的是:
text
来一个客户端
↓
fork 一个子进程
再来一个客户端
↓
再 fork 一个子进程
进程的创建和维护成本并不低。
因为不同进程拥有各自独立的:
text
虚拟地址空间
页表
PCB
资源管理结构
......
而我们的需求其实只是:
让不同客户端由不同执行流同时处理。
未必真的需要:
text
多个独立进程
因此接下来可以考虑一种更轻量的执行流:
线程。
于是服务器继续演进:
text
单进程串行
↓
多进程并发
↓
多线程并发
4. 多线程版 TCP Server
多进程版本能够工作。
但每来一个客户端就:
cpp
fork();
仍然比较重。
不同进程:
text
拥有独立虚拟地址空间
而线程:
text
属于同一个进程
共享地址空间
共享文件描述符表
共享大量进程资源
所以对于这种:
text
一个连接
↓
交给一个独立执行流
的问题,也可以使用线程。
整体结构:
text
主线程
↓
accept
↓
创建新线程
↓
继续 accept
工作线程
↓
Service
4.1 ThreadData:怎么把参数传给线程?
pthread_create() 的线程入口形式:
cpp
void* Routine(void*);
而:
cpp
Service();
需要:
text
service_sock
client IP
client Port
TcpServer 对象本身
一个:
cpp
void*
显然直接装不下这么多独立参数。
所以把它们封装成一个对象:
cpp
class ThreadData
{
public:
ThreadData(int sock, const std::string& ip, uint16_t port, TcpServer* current)
: sock_(sock), clientip_(ip), clientport_(port), current_(current)
{}
public:
int sock_;
std::string clientip_;
uint16_t clientport_;
TcpServer* current_;
};
然后:
cpp
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);
把:
cpp
td
作为:
cpp
void*
传给线程。
4.2 为什么线程函数是 static?
普通成员函数实际上隐藏了:
cpp
this
参数。
而:
cpp
pthread_create();
要求的是普通函数形式:
cpp
void* (*)(void*);
所以类内线程入口一般写成:
cpp
static
cpp
static void* Routine(void* args)
{
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
td->current_->Service(td->sock_, td->clientip_, td->clientport_);
delete td;
return nullptr;
}
其中:
cpp
pthread_detach(pthread_self());
表示:
当前线程退出以后,系统自动回收线程资源,不要求主线程再
pthread_join()。
4.3 多线程 StartServer
cpp
void StartServer()
{
while(true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
continue;
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
}
}
与多进程版本有一个非常重要的区别。
多进程:
text
父子进程有各自的文件描述符表
所以父进程可以:
cpp
close(service_sock);
只关闭自己那一份引用。
但是线程之间:
text
共享同一个进程的文件描述符表
所以主线程不能像多进程一样随便再:
cpp
close(service_sock);
否则工作线程使用的也是这个文件描述符。
4.4 多线程版 TCP Server 完整代码
到这里,多线程版本需要的内容已经全部具备了:
text
主线程
↓
accept()
↓
获得 service_sock
↓
构造 ThreadData
↓
pthread_create()
↓
继续 accept()
工作线程
↓
Routine()
↓
pthread_detach()
↓
Service()
↓
专门服务当前客户端
和多进程版本相比,它们解决的问题完全相同:
不能让一个客户端的
Service()占住服务器唯一的执行流。
但是实现方式发生了变化:
text
多进程
↓
一个客户端对应一个子进程
多线程
↓
一个客户端对应一个工作线程
现在把前面的内容组合起来,得到完整的多线程 TCP Server。
当前项目结构仍然是:
text
TcpServer
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile
客户端代码不需要修改,继续使用前面已经完成的 TCP Client。
这一阶段真正修改的仍然只有 Server。
server.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace fxs_server
{
const uint16_t default_port = 8877;
const std::string default_ip = "0.0.0.0";
const int backlog = 5;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
class TcpServer;
// 将线程执行Service所需要的数据统一封装起来
class ThreadData
{
public:
ThreadData(int sock, const std::string& ip, uint16_t port, TcpServer* current)
: sock_(sock), clientip_(ip), clientport_(port), current_(current)
{}
public:
int sock_;
std::string clientip_;
uint16_t clientport_;
TcpServer* current_;
};
using func_t = std::function<std::string(std::string)>;
class TcpServer
{
public:
TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
: ip_(ip), port_(port), func_(func)
{}
~TcpServer()
{}
void InitServer()
{
// 1.创建监听套接字
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock_ < 0)
{
std::cerr << "Create Socket Fail: "
<< strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success: "
<< listen_sock_ << std::endl;
// 2.绑定IP和Port
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "Bind IP&&Port Fail: "
<< strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "Bind IP&&Port Success" << std::endl;
// 3.进入监听状态
if(listen(listen_sock_, backlog) < 0)
{
std::cerr << "Listen Fail: "
<< strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "Listen Success" << std::endl;
}
void StartServer()
{
while(true)
{
// 1.主线程获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
{
std::cerr << "Accept Fail: "
<< strerror(errno) << std::endl;
continue;
}
// 2.获取客户端IP和Port
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
uint16_t clientport = ntohs(client.sin_port);
std::cout << "Get a new connection -> "
<< "[" << clientip << ":" << clientport << "] "
<< "service_sock: " << service_sock
<< std::endl;
// 3.构造线程需要的参数
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);
// 4.创建线程,让工作线程负责当前客户端
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
// 主线程不进入Service,直接继续下一轮accept
}
}
// 工作线程入口函数
static void* Routine(void* args)
{
// 分离当前线程,退出后自动回收资源
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
// 调用当前TcpServer对象的Service
td->current_->Service(td->sock_, td->clientip_, td->clientport_);
delete td;
return nullptr;
}
// 通信服务 + 业务处理
void Service(int sock, const std::string& clientip, uint16_t clientport)
{
char buff[1024];
std::string who = clientip + ":" + std::to_string(clientport);
while(true)
{
ssize_t n = read(sock, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Server get: "
<< buff
<< " from "
<< who
<< std::endl;
std::string respond = func_(buff);
write(sock, respond.c_str(), respond.size());
}
else if(n == 0)
{
std::cout << "Client "
<< who
<< " quit!"
<< std::endl;
close(sock);
break;
}
else
{
std::cerr << "Read Fail: "
<< strerror(errno)
<< std::endl;
close(sock);
break;
}
}
}
private:
int listen_sock_;
std::string ip_;
uint16_t port_;
func_t func_;
};
}
这一版最核心的地方就是:
cpp
ThreadData* td = new ThreadData(service_sock, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
主线程得到连接以后,并不会自己执行:
cpp
Service();
而是创建一个线程:
text
service_sock
client IP
client Port
this
全部交给工作线程。
工作线程再执行:
cpp
td->current_->Service(td->sock_, td->clientip_, td->clientport_);
所以:
text
Main Thread
│
├── accept Client A
│ ↓
│ Thread A -> Service(A)
│
├── accept Client B
│ ↓
│ Thread B -> Service(B)
│
└── accept Client C
↓
Thread C -> Service(C)
主线程始终能够继续:
cpp
accept();
server.cc
业务层继续使用 Echo,不需要任何修改:
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
这里再次能够看到我们前面进行业务解耦的好处。
无论服务器使用:
text
单进程
多进程
多线程
业务函数始终还是:
cpp
std::string Echo(const std::string& request)
{
return request;
}
变化的是:
服务器如何安排执行流去处理不同客户端。
业务本身没有发生变化。
Makefile
由于现在使用:
cpp
pthread_create();
pthread_detach();
需要链接 pthread:
makefile
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11 -lpthread
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf server client
客户端仍然使用前面已经完成的:
text
client.hpp
client.cc
不需要修改。
4.5 运行多线程 TCP Server
重新编译:
bash
make clean
make
启动 Server:
bash
./server 8877
然后分别启动多个客户端。
Client A:
bash
./client 127.0.0.1 8877
Client B:
bash
./client 127.0.0.1 8877
Client C:
bash
./client 127.0.0.1 8877

三个 Client 都可以持续发送消息:
text
Client A
Please Enter >> hello A
Client B
Please Enter >> hello B
Client C
Please Enter >> hello C
并分别收到服务器 Echo。
此时服务器内部结构已经变成:
text
Server Process
│
├── Main Thread
│ ↓
│ accept()
│
├── Thread A
│ ↓
│ Service(Client A)
│
├── Thread B
│ ↓
│ Service(Client B)
│
└── Thread C
↓
Service(Client C)
这和多进程版本:
text
Parent
├── Child A
├── Child B
└── Child C
解决的是同一个问题。
只不过现在所有执行流都属于:
同一个 Server 进程。
使用 ps 查看线程
可以打开另一个终端:
bash
ps -aL | grep server
其中:
text
-L
表示:
显示进程中的线程。
假设目前有三个客户端,那么可以看到同一个:
text
PID
下面存在多个不同的:
text
LWP
大致可以理解为:
text
PID
↓
属于哪个进程
LWP
↓
具体哪个线程
所以你会看到:
text
一个 Server PID
+
多个不同的线程 ID
这与前面的多进程版本形成非常明显的对比。
多进程:
text
ps -ef
↓
多个不同 PID
多线程:
text
ps -aL
↓
同一个 PID
+
多个线程

4.6 多进程和多线程到这里到底差在哪?
现在正好可以把两个版本放在一起。
多进程:
text
Server Parent
│
├── Child A -> Client A
├── Child B -> Client B
└── Child C -> Client C
多线程:
text
Server Process
│
├── Main Thread
├── Thread A -> Client A
├── Thread B -> Client B
└── Thread C -> Client C
它们的共同点:
text
主执行流
↓
只负责accept
其他执行流
↓
负责Service
不同点:
text
多进程
↓
不同进程拥有独立虚拟地址空间
↓
资源隔离更强
↓
创建和切换相对更重
多线程
↓
多个线程属于同一个进程
↓
共享地址空间、文件描述符等资源
↓
创建和切换相对更轻
但是多线程版本现在还有一个问题。
当前是:
text
Client 来了
↓
pthread_create()
又来一个 Client
↓
再 pthread_create()
再来一个
↓
继续 pthread_create()
也就是说:
线程仍然是在客户端到来以后临时创建的。
如果客户端特别多:
text
连接越多
↓
创建线程越多
↓
线程数量缺乏统一控制
那么有没有办法:
程序启动的时候就提前创建好固定数量的线程,客户端来了以后只需要把任务交给它们?
这就是我们前面已经学习过的:
线程池。
于是服务器继续演进:
text
单进程
↓
多进程
↓
多线程
↓
线程池
5. 线程池版 TCP Server
现在多线程服务器还有一个问题。
当前逻辑:
text
客户端来了
↓
accept
↓
pthread_create
↓
创建线程
也就是说:
客户端已经来了,我们才临时创建线程。
如果连接数量不断增加:
text
来一个
↓
建一个线程
再来一个
↓
再建一个线程
......
线程数量就可能不断增长。
这并不是一个很好的资源管理方式。
于是我们自然想到前面已经学习过的:
线程池。
5.1 为什么使用线程池?
线程池:
text
程序启动
↓
提前创建固定数量线程
↓
所有线程等待任务
客户端来了以后:
text
accept()
↓
构造 Task
↓
Push 进入任务队列
↓
唤醒工作线程
↓
处理 Task
所以:
text
连接到来
和:
text
线程创建
不再绑定在一起。
整个结构:
text
accept
↓
Task Queue
↙ ↓ ↘
thread1 thread2 thread3
这本质上就是以前的:
生产者消费者模型。
主线程:
text
生产任务
工作线程:
text
消费任务
5.2 Task 到底是什么?
在当前 TCP Server 中,一份任务至少需要:
text
service_sock
client IP
client Port
业务处理函数
所以:
cpp
class Task
{
public:
Task(int sockfd, const std::string& clientip, uint16_t clientport, const func_t& func)
: sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_(func)
{}
void Run()
{
char buff[1024];
ssize_t n = read(sockfd_, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::string respond = func_(buff);
write(sockfd_, respond.c_str(), respond.size());
}
close(sockfd_);
}
private:
int sockfd_;
std::string clientip_;
uint16_t clientport_;
func_t func_;
};
然后:
cpp
Task task(service_sock, clientip, clientport, func_);
ThreadPool<Task>::GetInstance()->Push(task);
5.3 为什么这里去掉 Service 的 while(true)?
这个地方必须讲明白。
如果线程池只有:
text
5 个线程
而一个任务内部:
cpp
while(true)
{
read();
write();
}
表示:
一个 Worker 一旦服务一个长连接,就一直被这个连接占住。
那么:
text
5 个线程
最多就只能同时被:
text
5 个长期连接
占用。
所以这个教学版本采用:
text
一个连接
↓
读取一次
↓
处理一次
↓
返回一次
↓
关闭连接
的短服务模型。
因此如果 Client 仍然想在同一个 TCP 连接中发送第二条消息:
text
第一次
↓
Server 处理
↓
Server close
第二次
↓
原连接已经关闭
当然会失败。
这不是:
线程池导致 TCP 不能连续通信。
更不是:
TCP 一个连接只能发送一次数据。
而只是:
我们这里的
Task::Run()主动设计成了一次请求处理完就关闭连接。
如果业务需要:
text
TCP 长连接
完全可以重新设计任务模型。
这里只是利用它理解:
text
线程池
+
TCP Server
是如何结合的。
5.4 主线程加入线程池
核心代码:
cpp
void StartServer()
{
ThreadPool<Task>::GetInstance()->Start();
while(true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
continue;
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
Task task(service_sock, clientip, clientport, func_);
ThreadPool<Task>::GetInstance()->Push(task);
}
}
于是:
text
主线程
↓
只负责 accept + Push
线程池
↓
负责真正执行 Task::Run()
5.5 线程池版 TCP Server 完整代码
到这里,线程池版本需要的几个模块已经全部具备:
text
主线程
↓
accept()
↓
获得一个新的 service_sock
↓
构造 Task
↓
Push 到任务队列
↓
继续 accept()
线程池中的工作线程
↓
提前创建
↓
等待任务
↓
从任务队列取出 Task
↓
Task::Run()
↓
处理客户端请求
和前面的多线程版本相比,最大的变化就是:
text
多线程版
Client 到来
↓
pthread_create()
↓
临时创建工作线程
变成:
text
线程池版
Server 启动
↓
提前创建固定数量工作线程
↓
Client 到来
↓
只需要构造 Task
↓
放入任务队列
这一阶段项目结构变成:
text
TcpServer
├── ThreadPool.hpp
├── Task.hpp
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile
其中:
text
client.hpp
client.cc
继续使用前面已经完成的 TCP Client,不需要修改。
这一阶段新增的是:
text
ThreadPool.hpp
Task.hpp
并重新修改:
text
server.hpp
ThreadPool.hpp
这里直接使用我们之前学习过的线程池。
线程池本质上仍然是一个:
生产者消费者模型。
text
主线程
↓
生产 Task
↓
任务队列
工作线程
↓
消费 Task
↓
执行 Run()
代码如下:
cpp
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int default_num = 5;
template<class T>
class ThreadPool
{
private:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
public:
static void* HandlerTask(void* args)
{
ThreadPool<T>* tp = static_cast<ThreadPool<T>*>(args);
while(true)
{
tp->Lock();
while(tp->IsQueueEmpty())
tp->ThreadSleep();
T task = tp->Pop();
tp->Unlock();
task.Run();
}
return nullptr;
}
void Start()
{
int num = threads_.size();
for(int i = 0; i < num; i++)
{
threads_[i].name = "thread-" + std::to_string(i + 1);
pthread_create(&threads_[i].tid, nullptr, HandlerTask, this);
}
}
T Pop()
{
T task = tasks_.front();
tasks_.pop();
return task;
}
void Push(const T& task)
{
Lock();
tasks_.push(task);
Wakeup();
Unlock();
}
static ThreadPool<T>* GetInstance()
{
if(tp_ == nullptr)
{
pthread_mutex_lock(&lock_);
if(tp_ == nullptr)
tp_ = new ThreadPool<T>();
pthread_mutex_unlock(&lock_);
}
return tp_;
}
private:
ThreadPool(int num = default_num)
: threads_(num)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
ThreadPool(const ThreadPool<T>&) = delete;
ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;
private:
std::vector<ThreadInfo> threads_;
std::queue<T> tasks_;
pthread_mutex_t mutex_;
pthread_cond_t cond_;
static ThreadPool<T>* tp_;
static pthread_mutex_t lock_;
};
template<class T>
ThreadPool<T>* ThreadPool<T>::tp_ = nullptr;
template<class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;
线程池启动以后:
cpp
ThreadPool<Task>::GetInstance()->Start();
就会提前创建:
text
5 个 Worker
这些线程开始时没有任务,于是:
text
Lock
↓
发现任务队列为空
↓
pthread_cond_wait
↓
线程休眠
主线程以后:
cpp
Push(task);
就会:
text
任务入队
↓
pthread_cond_signal
↓
唤醒一个 Worker
↓
取出 Task
↓
Task::Run()
Task.hpp
那么什么是:
text
Task
呢?
前面的多线程版本把一个连接直接交给:
cpp
Service()
处理。
现在使用线程池以后,不能再:
text
accept
↓
创建线程
↓
Service
而是需要先把:
text
这次需要做的事情
封装成一个对象。
一份 TCP 服务任务至少需要知道:
text
service_sock
客户端 IP
客户端 Port
业务处理函数
因此:
cpp
Task task(service_sock, clientip, clientport, func_);
本质上就是:
把一次网络服务所需要的所有信息打包。
Task.hpp:
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <cstring>
#include <cerrno>
#include <unistd.h>
namespace fxs_server
{
using func_t = std::function<std::string(std::string)>;
class Task
{
public:
Task(int sockfd, const std::string& clientip, uint16_t clientport, const func_t& func)
: sockfd_(sockfd), clientip_(clientip), clientport_(clientport), func_(func)
{}
~Task()
{}
void Run()
{
char buff[1024];
std::string who = clientip_ + ":" + std::to_string(clientport_);
ssize_t n = read(sockfd_, buff, sizeof(buff) - 1);
if(n > 0)
{
buff[n] = '\0';
std::cout << "Server get: "
<< buff
<< " from "
<< who
<< std::endl;
std::string respond = func_(buff);
write(sockfd_, respond.c_str(), respond.size());
}
else if(n == 0)
{
std::cout << "Client "
<< who
<< " quit!"
<< std::endl;
}
else
{
std::cerr << "Read Fail: "
<< strerror(errno)
<< std::endl;
}
close(sockfd_);
}
private:
int sockfd_;
std::string clientip_;
uint16_t clientport_;
func_t func_;
};
}
仔细看:
cpp
void Run()
其实就是前面:
cpp
Service()
中的核心业务。
只不过它发生了一个非常重要的变化:
text
以前:
while(true)
{
read
business
write
}
现在:
read
↓
business
↓
write
↓
close
↓
Task结束
也就是把:
cpp
while(true)
去掉了。
server.hpp
使用线程池以后,前面的:
text
ThreadData
Routine
Service
都不再需要。
因为:
text
ThreadData
↓
原来负责给临时创建的线程传参数
而现在:
text
Task
↓
已经把任务需要的参数全部封装起来
所以 Server 只需要负责:
text
accept
↓
构造 Task
↓
Push
完整代码:
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "ThreadPool.hpp"
#include "Task.hpp"
namespace fxs_server
{
const uint16_t default_port = 8877;
const std::string default_ip = "0.0.0.0";
const int backlog = 5;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
class TcpServer
{
public:
TcpServer(const func_t& func, uint16_t port = default_port, const std::string& ip = default_ip)
: ip_(ip), port_(port), func_(func)
{}
~TcpServer()
{}
void InitServer()
{
// 1.创建监听套接字
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if(listen_sock_ < 0)
{
std::cerr << "Create Socket Fail: "
<< strerror(errno) << std::endl;
exit(SOCKET_ERR);
}
std::cout << "Create Socket Success: "
<< listen_sock_
<< std::endl;
// 2.绑定IP和Port
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port_);
local.sin_addr.s_addr = inet_addr(ip_.c_str());
if(bind(listen_sock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "Bind IP&&Port Fail: "
<< strerror(errno) << std::endl;
exit(BIND_ERR);
}
std::cout << "Bind IP&&Port Success" << std::endl;
// 3.进入监听状态
if(listen(listen_sock_, backlog) < 0)
{
std::cerr << "Listen Fail: "
<< strerror(errno) << std::endl;
exit(LISTEN_ERR);
}
std::cout << "Listen Success" << std::endl;
}
void StartServer()
{
// 线程池只需要在服务器启动时创建一次
ThreadPool<Task>::GetInstance()->Start();
while(true)
{
// 1.获取新连接
struct sockaddr_in client;
socklen_t len = sizeof(client);
int service_sock = accept(listen_sock_, (struct sockaddr*)&client, &len);
if(service_sock < 0)
{
std::cerr << "Accept Fail: "
<< strerror(errno)
<< std::endl;
continue;
}
// 2.获取客户端信息
char clientip[32];
inet_ntop(AF_INET, &client.sin_addr, clientip, sizeof(clientip));
uint16_t clientport = ntohs(client.sin_port);
std::cout << "Get a new connection -> "
<< "[" << clientip << ":" << clientport << "] "
<< "service_sock: " << service_sock
<< std::endl;
// 3.构造任务
Task task(service_sock, clientip, clientport, func_);
// 4.把任务交给线程池
ThreadPool<Task>::GetInstance()->Push(task);
}
}
private:
int listen_sock_;
std::string ip_;
uint16_t port_;
func_t func_;
};
}
这里整个 Server 最核心的变化,其实只有:
cpp
ThreadPool<Task>::GetInstance()->Start();
和:
cpp
Task task(service_sock, clientip, clientport, func_);
ThreadPool<Task>::GetInstance()->Push(task);
最终运行流程:
text
Server启动
↓
创建5个Worker
↓
Worker全部等待任务
Main Thread
↓
accept Client A
↓
Task A
↓
Push
Worker 1
↓
Pop Task A
↓
Run()
Main Thread
↓
继续accept Client B
↓
Task B
↓
Push
Worker 2
↓
Pop Task B
↓
Run()
这就是线程池版本最核心的结构。
server.cc
业务处理仍然完全不需要变化:
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
到这里其实已经连续验证了很多次业务解耦的意义。
从:
text
单进程
↓
多进程
↓
多线程
↓
线程池
服务器内部的调度方式一直在改变。
但:
cpp
std::string Echo(const std::string& request)
{
return request;
}
始终不需要改变。
也就是说:
text
网络通信 / 并发模型
和:
text
具体业务
已经基本分开了。
5.6 运行线程池版 TCP Server
重新编译:
bash
make clean
make
启动 Server:
bash
./server 8877
这时候一个 Client 都还没有连接。
马上执行:
bash
ps -aL | grep server
就应该能够看到:

一共:
text
6 个线程
其中:
text
1 个主线程
+
5 个线程池 Worker
注意:
此时还没有任何客户端连接。
这正是线程池和前一个多线程版本最直观的区别。
前一个版本:
text
Server刚启动
↓
只有主线程
Client A来了
↓
多一个线程
Client B来了
↓
再多一个线程
而线程池:
text
Server刚启动
↓
主线程 + 5个Worker已经全部存在
再连接客户端
启动:
bash
./client 127.0.0.1 8877
输入:
text
hello thread pool
流程:
text
Client
↓
connect
Server Main Thread
↓
accept
↓
Task
↓
Push
Worker
↓
Pop
↓
read
↓
Echo
↓
write
↓
close
客户端能够正常收到:
text
hello thread pool
但是如果继续在原来的连接中发送第二条消息,就会发现:
text
连接已经断开
这是正常的。
因为当前:
cpp
Task::Run()
在处理完一次请求以后执行:
cpp
close(sockfd_);
也就是说我们当前设计的是:
text
一次连接
↓
一次请求
↓
一次响应
↓
关闭
而不是:
text
建立一次连接
↓
while(true)
↓
长期通信
一定不要把这个现象理解成:
线程池不能处理 TCP 长连接。
它只能说明:
我们当前这个 Task 被人为设计成短任务。
5.7 三个版本放在一起看
现在正好可以把三个并发版本彻底串起来。
多进程
text
Client来了
↓
fork()
↓
Child
↓
Service
结构:
text
Parent
├── Child A
├── Child B
└── Child C
多线程
text
Client来了
↓
pthread_create()
↓
Thread
↓
Service
结构:
text
Server Process
├── Main Thread
├── Thread A
├── Thread B
└── Thread C
线程池
text
Server启动
↓
提前创建Worker
Client来了
↓
Task
↓
Queue
↓
已有Worker处理
结构:
text
Server Process
│
├── Main Thread
│ ↓
│ accept
│ ↓
│ Push
│
├── Worker 1 ─┐
├── Worker 2 │
├── Worker 3 ├── Task Queue
├── Worker 4 │
└── Worker 5 ─┘
所以整个服务器并发模型的演进其实非常自然:
text
单进程
↓
Service占住唯一执行流
↓
多进程
↓
能并发,但进程较重
↓
多线程
↓
更轻,但每来一个连接临时创建线程
↓
线程池
↓
提前创建固定数量线程
↓
连接来了只提交Task
到这里,我们已经从:
text
能通信
一步一步走到了:
text
如何更合理地组织服务器并发执行
下一步再继续解决:
怎样让这个 Server 脱离终端,长期运行在后台?
这就进入守护进程。
6. 把 TCP Server 变成守护进程
到目前为止,我们的 TCP Server 已经经历了:
text
单进程
↓
多进程
↓
多线程
↓
线程池
服务器已经具备了一定的并发处理能力。
但现在还有一个问题。
我们启动 Server 时使用:
bash
./server 8877
程序会一直占着当前终端运行。
例如:
text
终端
│
└── ./server 8877
↓
长期运行
而真正的服务器程序往往需要:
text
长期运行
+
不占用终端
+
不依赖当前终端是否一直存在
例如:
text
sshd
crond
这种程序,并不需要我们始终打开一个终端看着它们。
这就引出了:
守护进程(Daemon)。
守护进程可以简单理解为:
脱离控制终端、长期在后台运行并提供某种服务的进程。
我们的 TCP Server 本身就是一个非常典型的适合守护进程化的程序。
6.1 先看看现在的 Server 到底和终端有什么关系
先暂时不要做任何守护进程化。
正常启动:
bash
./server 8877
然后再打开一个终端执行:
bash
ps axj | head -1
看到

这里突然出现了一堆以前不太关注的字段:
text
PID
PPID
PGID
SID
TPGID
TTY
其中:
text
PID
↓
当前进程是谁
PPID
↓
当前进程的父进程是谁
PGID
↓
当前进程属于哪个进程组
SID
↓
当前进程属于哪个会话
TPGID
↓
当前终端的前台进程组
TTY
↓
当前进程关联的终端
例如:
text
TTY = pts/5
就说明:
当前 Server 仍然处在
pts/5这个终端的控制体系中。
所以如果想真正理解守护进程,我们首先要把:
text
进程
进程组
作业
会话
控制终端
这几个概念串起来。
6.2 从一个终端理解进程组、作业和会话
这些概念如果分开背会非常乱。
直接记住下面这条关系:
text
一个终端
↓
对应一套会话 Session
↓
Session 中可以存在多个进程组 Process Group
↓
Shell 利用进程组进行作业控制
↓
其中最多有一个前台进程组
也就是说,可以粗略理解为:
text
Session
│
├── Foreground Process Group
│ ├── Process
│ └── Process
│
├── Background Process Group
│ └── Process
│
└── Background Process Group
├── Process
└── Process
接下来分别看看它们到底是什么。
6.2.1 进程组
进程除了有自己的:
text
PID
之外,还会属于一个:
text
Process Group
也就是:
进程组。
每个进程组都有:
text
PGID
一个进程组中可以包含多个相关进程。
例如我们前面写多进程 TCP Server 时:
text
Server父进程
├── Server子进程A
├── Server子进程B
└── Server子进程C
使用 ps 查看时就可能发现:
text
PID 不同
PGID 相同
也就是说:
text
它们是不同进程
↓
但属于同一个进程组
所以:
text
PID
↓
标识单独一个进程
PGID
↓
标识一组相关进程
为什么操作系统还需要把进程组成一个组?
其中一个非常重要的原因,就是:
方便对一组相关进程统一进行控制。
例如终端产生某些信号时,可以直接发送给:
text
一个前台进程组
而不是只发送给某一个进程。
6.2.2 作业
再看 Shell 中一个非常常见的现象。
执行:
bash
sleep 100
此时终端会被它占用。
在 sleep 结束之前,Shell 不会重新给我们命令提示符。
这种就可以看作:
前台作业。
而如果:
bash
sleep 100 &
Shell 会立即返回:
text
$
此时:
text
sleep 100
仍然在运行,但是已经成为:
后台作业。
所以 Shell 中:
text
前台
后台
jobs
fg
bg
这些功能,本质上都属于:
作业控制(Job Control)。
Shell 在实现作业控制时,会大量使用:
text
进程组
这个操作系统机制。
因此:
text
作业
更多是:
Shell 看待和管理用户任务的角度。
而:
text
进程组
则是:
操作系统真正用来组织进程的机制。
6.2.3 会话
比进程组范围更大的概念叫:
text
Session
也就是:
会话。
一个会话中可以包含多个:
text
Process Group
所以层级关系:
text
Session
↓
Process Group
↓
Process
例如:
text
Session
│
├── Process Group A
│ ├── Process 1
│ └── Process 2
│
├── Process Group B
│ └── Process 3
│
└── Process Group C
├── Process 4
└── Process 5
一个会话中:
text
最多有一个前台进程组
+
可以有多个后台进程组
而一个会话还可能拥有:
控制终端。
这就把最后一个概念串起来了。
6.3 控制终端到底控制了什么?
我们平时看终端,容易觉得它只是一个:
text
输入命令
+
显示输出
的窗口。
实际上终端还参与了:
进程控制。
最典型的例子就是:
text
Ctrl + C
假设当前运行:
bash
./server 8877
然后按:
text
Ctrl + C
Server 会退出。
这里并不是:
Shell 发现你按了 Ctrl+C,然后自己调用
kill(server_pid)。
更接近真实的过程是:
text
Ctrl + C
↓
终端驱动识别特殊字符
↓
产生 SIGINT
↓
发送给当前终端的前台进程组
所以:
text
控制终端
不仅负责输入输出,还参与:
text
前台进程组
+
作业控制
+
终端产生的信号
例如:
text
Ctrl+C
↓
SIGINT
Ctrl+Z
↓
SIGTSTP
都会和:
text
当前前台进程组
发生关系。
现在再回头看 Server:
text
./server 8877
↓
属于当前Session
↓
属于某个进程组
↓
仍然关联当前控制终端
所以它还没有真正成为一个:
text
独立运行的后台服务
6.4 后台进程就是守护进程吗?
既然我们只是嫌 Server 占着终端,那能不能直接:
bash
./server 8877 &
不就行了吗?
这样确实会立即返回 Shell。
但是:
后台进程并不等于守护进程。
执行:
bash
./server 8877 &
只是让 Server 作为:
text
Shell 的后台作业
运行。
它通常仍然处在:
text
原来的 Session
+
原来的终端体系
之中。
可以执行:
bash
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -C server
继续观察。
通常仍然能够看到类似:
text
TTY = pts/5
SID = 当前终端对应的Session
也就是说:
text
./server &
只是:
text
不要占据Shell前台
并没有真正完成:
text
脱离原会话
脱离控制终端
所以真正的守护进程需要继续做更多工作。
6.5 setsid:真正脱离原来的会话
Linux 提供:
cpp
#include <unistd.h>
pid_t setsid(void);
调用:
cpp
setsid();
成功以后,当前进程会:
text
创建一个新的 Session
↓
自己成为 Session Leader
↓
同时创建一个新的 Process Group
↓
自己成为 Process Group Leader
↓
脱离原来的控制终端
这一步正是:
守护进程化最核心的操作。
如果我们原来是:
text
原Session
│
├── Shell
└── Server
↓
pts/5
调用:
cpp
setsid();
成功以后:
text
原Session
│
└── Shell
↓
pts/5
新Session
│
└── Server
↓
无控制终端
这样 Server 才真正从原来的终端控制体系中脱离出来。
6.6 为什么不能直接调用 setsid?
这里还有一个限制:
如果当前进程已经是进程组组长,那么
setsid()会失败。
所以经典守护进程的第一步并不是:
cpp
setsid();
而是:
cpp
fork();
整体过程:
text
当前进程
↓
fork()
↓
┌──────────────┐
│ │
父进程 子进程
│ │
退出 继续
↓
setsid()
为什么新创建的子进程适合调用:
cpp
setsid();
呢?
因为子进程:
text
PID
↓
是一个新PID
但是它会继承父进程原来的:
text
PGID
所以通常:
text
子进程 PID != PGID
也就是说:
这个新子进程不是原进程组的组长。
于是可以成功:
cpp
setsid();
因此第一次 fork() 的一个非常重要的目的就是:
得到一个肯定不是原进程组组长的子进程,从而安全调用
setsid()。
6.7 为什么经典守护进程还要再 fork 一次?
现在:
text
第一次fork
↓
父进程退出
↓
子进程setsid()
当前子进程已经:
text
脱离原控制终端
+
成为新Session Leader
似乎已经够了。
但经典的守护进程实现通常还会:
cpp
fork();
第二次。
原因在于:
调用
setsid()的进程已经成为了 Session Leader。
一个 Session Leader 在特定条件下仍存在:
text
重新获得控制终端
的可能。
所以再:
text
Session Leader
↓
fork()
↓
┌────────────────┐
│ │
父进程 子进程
Session Leader 最终Daemon
│ │
退出 继续运行
最终真正提供服务的进程:
text
不是 Session Leader
因此进一步避免它以后重新获得控制终端。
所以经典双重 fork() 的核心逻辑:
text
第一次fork
↓
为了能够setsid
setsid
↓
创建新会话并脱离控制终端
第二次fork
↓
让最终Daemon不再是Session Leader
↓
避免重新获得控制终端
需要特别注意:
第二次
fork()的主要目的不是解决僵尸进程问题。
它解决的是:
Session Leader 仍可能重新获得控制终端的问题。
6.8 守护进程还需要处理什么?
完成:
text
fork
↓
setsid
↓
fork
以后,控制终端的问题基本解决了。
但是还有几个运行环境需要处理。
6.8.1 修改当前工作目录
假设我们在:
text
/home/lenovo/code/TcpServer
启动 Server。
当前进程的:
text
current working directory
也是:
text
/home/lenovo/code/TcpServer
守护进程可能长期运行几小时、几天,甚至更久。
经典守护进程通常会:
cpp
chdir("/");
把工作目录切换到根目录。
这样可以避免守护进程长期占用某个特定目录所在的文件系统。
text
Daemon
↓
chdir("/")
↓
不再依赖启动时所在目录
不过这里也要注意:
如果程序以后需要通过相对路径访问配置文件、网页资源等内容,就不能随便
chdir("/")。
例如以后 HTTP Server 使用:
text
./wwwroot
作为网站根目录,那么突然:
cpp
chdir("/");
就会导致:
text
./wwwroot
的含义发生变化。
因此生产程序应该根据自己的实际需求选择:
text
/
或者:
text
某个固定工作目录
当前 TCP Echo Server 不依赖相对路径,因此使用:
cpp
chdir("/");
没有问题。
6.8.2 修改 umask
进程会继承父进程的:
text
umask
而守护进程以后可能会创建:
text
日志文件
配置文件
PID文件
临时文件
为了避免启动它的 Shell 环境影响文件权限,可以重新设置:
cpp
umask(0);
这样守护进程后续创建文件时,就不会额外继承原进程的权限屏蔽规则。
当前只是为了学习经典守护进程流程。
实际项目中也可以根据安全需求设置一个明确的:
text
umask
而不是一定使用 0。
6.9 stdin、stdout、stderr 怎么办?
这里还有一个非常关键的问题。
我们当前 Server 中大量使用:
cpp
std::cout
std::cerr
本质上最终都会使用:
text
stdout
stderr
也就是:
text
fd 1
fd 2
正常前台程序:
text
fd 0 -> 当前终端
fd 1 -> 当前终端
fd 2 -> 当前终端
但是现在我们不是已经说:
守护进程要脱离终端吗?
所以:
text
0
1
2
就不应该继续依赖原来的终端。
经典做法之一就是:
全部重定向到
/dev/null。
/dev/null 是什么?
Linux 中:
text
/dev/null
是一个特殊设备文件。
可以简单理解成:
数据黑洞。
向它写数据:
bash
echo "hello" > /dev/null
数据会直接被丢弃。
读取:
text
/dev/null
则会立即得到 EOF。
因此对于守护进程:
text
stdin
stdout
stderr
暂时不需要时,可以全部重定向到:
text
/dev/null
使用 dup2 完成重定向
先打开:
cpp
int fd = open("/dev/null", O_RDWR);
然后:
cpp
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
最终:
text
fd 0 ─┐
fd 1 ─┼──> /dev/null
fd 2 ─┘
这样:
cpp
std::cout << "hello";
虽然代码仍然执行,但内容最终进入:
text
/dev/null
直接被丢弃。
所以一旦 Server 真正守护进程化以后:
原来终端里的
cout/cerr日志看不到是正常现象。
真正的服务器以后应该使用:
text
日志文件
syslog
专业日志系统
记录运行信息。
6.10 手动实现完整守护进程
到这里,创建守护进程的所有步骤就都能解释清楚了。
完整流程:
text
当前进程
↓
fork()
↓
父进程退出
↓
子进程继续
↓
setsid()
↓
创建新Session
↓
脱离控制终端
↓
再次fork()
↓
Session Leader退出
↓
最终Daemon不是Session Leader
↓
umask()
↓
chdir()
↓
打开/dev/null
↓
dup2()
↓
0/1/2全部脱离终端
现在就把它真正写出来。
新增:
text
Daemon.hpp
当前项目:
text
TcpServer
├── Daemon.hpp
├── ThreadPool.hpp
├── Task.hpp
├── server.hpp
├── server.cc
├── client.hpp
├── client.cc
└── Makefile
6.10.1 Daemon.hpp
cpp
#pragma once
#include <cstdlib>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
namespace fxs_daemon
{
enum
{
FORK_ERR = 1,
SETSID_ERR,
CHDIR_ERR,
OPEN_ERR,
DUP_ERR
};
void Daemon()
{
// 1.第一次fork
pid_t id = fork();
if(id < 0)
exit(FORK_ERR);
if(id > 0)
_exit(0);
// 2.创建新的Session,脱离原控制终端
if(setsid() < 0)
exit(SETSID_ERR);
// 3.第二次fork,让最终进程不再是Session Leader
id = fork();
if(id < 0)
exit(FORK_ERR);
if(id > 0)
_exit(0);
// 4.重新设置文件权限掩码
umask(0);
// 5.修改当前工作目录
if(chdir("/") < 0)
exit(CHDIR_ERR);
// 6.打开/dev/null
int fd = open("/dev/null", O_RDWR);
if(fd < 0)
exit(OPEN_ERR);
// 7.重定向标准输入、标准输出、标准错误
if(dup2(fd, STDIN_FILENO) < 0 ||
dup2(fd, STDOUT_FILENO) < 0 ||
dup2(fd, STDERR_FILENO) < 0)
{
exit(DUP_ERR);
}
if(fd > STDERR_FILENO)
close(fd);
}
}
这里使用:
cpp
_exit(0);
而不是普通:
cpp
exit(0);
是因为:
fork()以后父子进程会继承用户层的一些缓冲状态。
_exit() 会直接结束当前进程,不再重复处理用户层缓冲区和退出处理函数。
在这种:
text
fork后父进程立即退出
的场景下更加直接。
6.10.2 接入当前 TCP Server
现在服务器已经是线程池版本。
其他代码:
text
ThreadPool.hpp
Task.hpp
server.hpp
client.hpp
client.cc
全部不用修改。
只需要修改:
text
server.cc
引入:
cpp
#include "Daemon.hpp"
然后在真正创建服务器之前:
cpp
Daemon();
完整:
cpp
#include <iostream>
#include <memory>
#include "server.hpp"
#include "Daemon.hpp"
using namespace std;
using namespace fxs_server;
using namespace fxs_daemon;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
// 将当前进程守护进程化
Daemon();
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
这里一定要注意调用顺序:
text
解析参数
↓
Daemon()
↓
InitServer()
↓
StartServer()
我们是在:
cpp
socket();
之前完成守护进程化。
因此当前:
text
listen_sock
service_sock
都还没有创建。
这样就不会出现守护进程化过程中误处理 Server Socket 的问题。
6.11 真正运行并观察守护进程
现在重新:
bash
make clean
make
启动:
bash
./server 8877
会发现:
命令几乎立即返回了。
例如:
text
lenovo@LAPTOP:~/code/TcpServer$ ./server 8877
lenovo@LAPTOP:~/code/TcpServer$
但是:
Server 并没有退出。
执行:
bash
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -C server
应该能够看到 Server 仍然存在。
最值得观察的是:
text
TTY
守护进程化之前:
text
TTY
↓
pts/5
守护进程化以后通常:
text
TTY
↓
?
也就是说:
text
之前
Server -> pts/5
之后
Server -> 无控制终端
这才是:
守护进程真正应该观察的现象。
再看 PID、PGID 和 SID
双重 fork() 后,最终运行 Server 的进程:
text
已经不是原来的那个进程
并且:
text
最终Daemon
↓
不是Session Leader
所以最终可能观察到:
text
PID != SID
甚至:
text
PID != PGID
这正是第二次:
cpp
fork();
留下来的结果。
原来调用:
cpp
setsid();
的那个进程:
text
PID = SID = PGID
但是它随后:
text
第二次fork
↓
自己退出
最终留下的子进程继承:
text
Session
+
Process Group
但它自己的:
text
PID
已经变成新的值。
因此最终 Daemon:
text
属于这个新Session
↓
但自己不是Session Leader
这正是我们想要的效果。
6.12 脱离终端以后还能正常提供 TCP 服务吗?
当然可以。
守护进程化改变的是:
text
Server 如何运行
而不是:
text
TCP 如何通信
所以 Client 仍然正常:
bash
./client 127.0.0.1 8877
发送:
text
hello daemon
服务器仍然:
text
accept
↓
Task
↓
ThreadPool
↓
Echo
↓
返回Client
客户端仍然能够收到:
text
hello daemon
因此:
text
Daemon
和:
text
TCP
并不存在什么新的网络关系。
它只是把:
text
前台TCP Server
变成:
text
后台长期运行的TCP Server
6.13 Linux 已经提供了 daemon()
手动写完以后,我们已经真正知道:
text
守护进程为什么要fork
为什么需要setsid
为什么再fork
为什么处理工作目录
为什么处理umask
为什么处理0、1、2
实际上 Linux/glibc 已经提供了一个非常方便的函数:
cpp
#include <unistd.h>
int daemon(int nochdir, int noclose);
使用:
cpp
daemon(0, 0);
其中:
text
nochdir
控制:
text
是否改变工作目录
当:
cpp
nochdir == 0
时,会切换工作目录。
第二个参数:
text
noclose
控制:
text
是否处理stdin/stdout/stderr
当:
cpp
noclose == 0
时,会将标准输入、标准输出、标准错误处理到:
text
/dev/null
因此:
cpp
daemon(0, 0);
就可以帮助我们完成常见的守护进程化操作。
需要注意:
glibc 的
daemon()并不是把我们上面的"双重 fork"代码一字不差地封装起来。
它完成的是常见的:
text
fork
↓
setsid
↓
chdir
↓
处理0、1、2
但 glibc 的实现不会再进行我们前面经典流程中的第二次 fork()。
因此调用成功后产生的进程仍可能是:
text
Session Leader
对于我们当前学习 TCP Server 来说:
cpp
daemon(0, 0);
已经足够方便地完成实验。
但手写经典双重 fork() 仍然有意义,因为它能让我们真正理解:
守护进程到底是怎样从原来的终端环境中脱离出来的。
6.14 使用 daemon() 简化 Server
如果不想自己维护:
text
Daemon.hpp
那么:
text
server.cc
甚至可以直接写成:
cpp
#include <iostream>
#include <memory>
#include <unistd.h>
#include "server.hpp"
using namespace std;
using namespace fxs_server;
std::string Echo(const std::string& request)
{
return request;
}
void Usage(const char* program)
{
cout << "Usage:" << endl;
cout << "\t" << program << " ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
return USAGE_ERR;
}
uint16_t port = stoi(argv[1]);
if(daemon(0, 0) == -1)
return 1;
unique_ptr<TcpServer> server(new TcpServer(Echo, port));
server->InitServer();
server->StartServer();
return 0;
}
也就是把前面一整套守护进程化过程,在当前 Linux 环境下简化成:
cpp
daemon(0, 0);
但是现在我们再看到这一行时,已经不会只是:
"背一个 daemon API。"
而是能够知道它背后解决的到底是什么问题:
text
普通Server
↓
属于当前Session
↓
关联控制终端
↓
终端会参与作业控制
守护进程化
↓
创建独立Session
↓
脱离原控制终端
↓
处理运行目录
↓
处理文件权限掩码
↓
处理stdin/stdout/stderr
↓
长期后台运行
6.15 这一节到底要记什么?
守护进程这一块概念很多,但真正复习的时候不需要把所有名词拆开背。
只需要顺着这一条因果链:
text
我的TCP Server为什么要守护进程化?
↓
因为Server应该长期运行,不应该依赖一个终端一直存在
为什么普通 ./server & 不够?
↓
因为它只是后台作业,仍然处于原来的Session和终端体系中
为什么终端还能控制进程?
↓
因为进程属于进程组
↓
进程组属于Session
↓
Session可能关联控制终端
↓
终端通过前台进程组进行作业控制
怎么真正脱离原终端?
↓
setsid()
为什么setsid前先fork?
↓
进程组组长不能成功setsid
↓
fork出的子进程不是原进程组组长
setsid之后发生什么?
↓
创建新Session
↓
成为Session Leader
↓
创建新Process Group
↓
成为Process Group Leader
↓
脱离原控制终端
为什么还要第二次fork?
↓
让最终Daemon不再是Session Leader
↓
避免以后重新获得控制终端
然后还要处理什么?
↓
umask
chdir
stdin/stdout/stderr
stdin/stdout/stderr放哪?
↓
/dev/null
最终怎么验证?
↓
ps查看
↓
TTY从pts/x变成?
↓
Client仍然可以正常访问Server
如果这条能够顺着讲下来:
进程组、作业、会话、控制终端、setsid、双重 fork 和守护进程就不再是七八个互不相干的知识点,而是一整套因果关系。
到这里,我们才算真正把:
text
TCP Server
从一个普通前台程序,变成了一个更符合服务器运行形态的:
text
后台服务程序
接下来再回到 TCP 本身,从内核角度重新理解:
text
connect
accept
三次握手
四次挥手
Socket缓冲区
全双工
字节流
这些接口背后到底发生了什么。
7. 再从底层重新理解 TCP
程序写到这里,我们已经会用了。
接下来把刚才写代码时看到的现象重新串起来。
这一部分比代码本身更重要。
7.1 connect、accept 和三次握手到底是什么关系?
应用层:
text
Client
↓
connect()
Server
↓
accept()
很容易让人产生错觉:
text
connect
+
accept
=
三次握手
实际上不是。
真正的 TCP 连接建立发生在:
客户端和服务器操作系统中的 TCP 协议栈。
可以理解成:
text
Client Application
│
connect()
↓
Client TCP
│
│ 三次握手
↓
Server TCP
│
已建立连接
↓
完成连接队列
│
accept()
↓
Server Application
所以:
cpp
connect();
是:
应用层告诉内核:我要主动连接这个 Server。
cpp
accept();
是:
Server 应用从内核已经建立完成的连接中拿走一个结果。
因此:
accept 不参与三次握手的具体过程。
即使应用暂时没有:
cpp
accept();
只要队列还有空间,TCP 三次握手仍然可能完成。
这也是为什么前面的:
text
Client B
能够显示:
text
connect 成功
但 Server 程序仍然卡在:
text
Service(Client A)
7.2 TCP 连接需要成本吗?
当然需要。
服务器同时维护:
text
Client A
Client B
Client C
......
操作系统不能只记一句:
"我和 A 连着呢。"
它至少必须维护大量 TCP 状态:
text
对端 IP
对端 Port
本端 IP
本端 Port
TCP 状态
序号信息
确认信息
发送相关状态
接收相关状态
缓冲区
......
因此内核需要:
text
先描述连接
↓
形成内核对象
↓
再用数据结构组织这些对象
所以:
TCP 连接本身是有时间和空间成本的。
这也是为什么实际服务器不能无限维护连接。
7.3 简单理解三次握手
Client 主动连接:
text
Client Server
SYN
───────────────────────────>
SYN + ACK
<───────────────────────────
ACK
───────────────────────────>
TCP Connection Established
这个过程由:
text
两端 TCP 协议栈
负责。
应用层主要看到的现象就是:
text
connect 成功
accept 返回
具体三次握手为什么一定是三次、序号如何交换、为什么能够避免历史连接等问题,后面的 TCP 协议进阶再详细展开。
这里暂时只建立:
text
TCP 通信之前
↓
先建立连接
↓
建立连接依赖三次握手
这条主线。
7.4 简单理解四次挥手
假设 Client 主动关闭:
text
Client Server
FIN
───────────────────────────>
ACK
<───────────────────────────
FIN
<───────────────────────────
ACK
───────────────────────────>
应用层看到:
cpp
close(client_sock);
会触发 TCP 关闭过程。
Server:
cpp
read();
最终读取到:
text
0
就能够知道:
对端这一方向已经关闭。
Server 随后也:
cpp
close();
触发自己这一侧的关闭流程。
所以:
text
close()
read() == 0
都是应用层看到的接口现象。
真正的:
text
FIN
ACK
TCP 状态转换
仍然由内核 TCP 协议栈完成。
7.5 TCP 通信的本质:先拷到内核缓冲区
假设应用代码:
cpp
char buff[1024];
write(sock, buff, n);
千万不要理解成:
write()一调用,CPU 就立刻把这些数据直接发送到另一台电脑。
更准确地说:
text
用户层 buff
↓
write()
↓
内核 Socket 发送缓冲区
随后:
TCP 协议栈什么时候真正发送、怎么分段、什么时候重传,由内核自己决定。
接收也是:
text
网络数据
↓
TCP
↓
Socket 接收缓冲区
↓
read()
↓
应用层 buff
所以从应用层角度:
cpp
write();
首先做的是:
把用户层数据交给内核。
cpp
read();
首先做的是:
把内核已经收到的数据拷贝到用户缓冲区。
7.6 为什么 TCP 是全双工?
每条 TCP 连接都能够:
text
Client -> Server
同时:
text
Server -> Client
双方互不排斥。
可以粗略理解成两端都存在自己的:
text
发送方向
+
接收方向
例如:
text
Client 发送缓冲区
↓
Server 接收缓冲区
Server 发送缓冲区
↓
Client 接收缓冲区
所以:
Client 正在给 Server 发数据,并不妨碍 Server 同时给 Client 发数据。
这就是 TCP:
text
全双工
的重要体现。
7.7 TCP 最大的问题:它是字节流,不是消息流
我们前面的 Echo 程序还有一个非常重要的问题。
Client:
cpp
write(sock, "hello", 5);
Server:
cpp
read(sock, buff, sizeof(buff));
我们当前代码默认:
text
一次 write
↓
对应一次 read
但 TCP 根本没有这种保证。
TCP 只提供:
连续、有序、可靠的字节流。
例如客户端:
text
write("hello")
write("world")
Server 有可能:
text
第一次 read
↓
helloworld
也可能:
text
第一次
↓
hel
第二次
↓
loworld
甚至还可能有其他组合。
所以:
text
发送了几次
和:
text
对方 read 了几次
没有一一对应关系。
这和管道非常类似:
text
写端
↓
连续写入字节流
读端
↓
根据当前缓冲区状态读取
因此真实 TCP 应用一定要解决:
怎么判断一条完整消息到底从哪里开始、在哪里结束?
答案就是:
应用层协议。
例如:
text
固定长度
或者:
text
特殊分隔符
或者:
text
长度字段 + 正文
这些将在后面的协议设计中继续解决。
所以一定记住:
text
TCP 保证字节可靠有序
≠
TCP 帮你保存应用层消息边界
7.8 网络字节序不要和应用数据混在一起
这里还要纠正一个特别容易混淆的问题。
像:
cpp
sin_port
这样的协议字段,需要:
cpp
htons();
ntohs();
因为 TCP/IP 协议规定这些数字字段使用:
text
网络字节序
但是:
cpp
write(sock, buff, n);
read(sock, buff, n);
并不会自动把你:
text
buff
里的所有整数从小端改成大端,或者再自动改回来。
例如:
cpp
int x = 0x12345678;
write(sock, &x, sizeof(x));
write() 只会把:
text
x 在当前内存中的 4 个字节
交给内核。
不会理解:
这 4 个字节原来是一个
int,我替你做一下htonl()。
因此真正需要跨平台传递数字时:
text
程序员自己定义协议
↓
明确哪些字段是数字
↓
自己完成序列化和字节序转换
网络接口负责的是:
text
传输字节
而不是理解你应用层数据的类型。
7.9 TIME_WAIT 和服务器快速重启
测试时可能遇到:
text
关闭 Server
↓
立刻重新启动
↓
bind: Address already in use
这和 TCP 连接关闭后的状态有关。
在 TCP 四次挥手中:
主动关闭的一方可能进入 TIME_WAIT。
所以不能简单记成:
text
Server 一定 TIME_WAIT
而应该记:
text
谁主动完成关闭
↓
谁可能进入 TIME_WAIT
如果服务器开发过程中需要频繁重启,可以使用:
cpp
setsockopt();
设置:
text
SO_REUSEADDR
后面马上封装。
8. 封装 Socket 接口
到现在我们已经反复写过:
cpp
socket();
bind();
listen();
accept();
connect();
close();
这些代码。
继续往后的网络项目还会不断使用。
所以应该将它们统一封装。
8.1 Socket.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
enum
{
SOCKET_ERR = 2,
BIND_ERR,
LISTEN_ERR
};
const int backlog = 10;
class Sock
{
public:
Sock()
: sockfd_(-1)
{}
~Sock()
{}
void Socket()
{
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd_ < 0)
{
std::cerr << "socket error: "
<< strerror(errno)
<< std::endl;
exit(SOCKET_ERR);
}
int opt = 1;
setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
void Bind(uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(sockfd_, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error: "
<< strerror(errno)
<< std::endl;
exit(BIND_ERR);
}
}
void Listen()
{
if(listen(sockfd_, backlog) < 0)
{
std::cerr << "listen error: "
<< strerror(errno)
<< std::endl;
exit(LISTEN_ERR);
}
}
int Accept(std::string* clientip, uint16_t* clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len);
if(newfd < 0)
return -1;
char ipstr[64];
inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
*clientip = ipstr;
*clientport = ntohs(peer.sin_port);
return newfd;
}
bool Connect(const std::string& ip, uint16_t port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);
int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer));
if(n == -1)
return false;
return true;
}
void Close()
{
close(sockfd_);
}
int Fd() const
{
return sockfd_;
}
private:
int sockfd_;
};
以后服务器初始化就从:
text
一大堆系统调用
变成:
cpp
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
获取连接:
cpp
int sockfd = listensock_.Accept(&clientip, &clientport);
客户端连接:
cpp
sock_.Socket();
sock_.Connect(server_ip, server_port);
代码结构会清楚很多。
8.2 setsockopt
刚才:
cpp
Socket();
中还有:
cpp
int opt = 1;
setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt() 用来:
修改 Socket 的各种工作选项。
Linux 常见函数形式:
cpp
int setsockopt(int sockfd,
int level,
int optname,
const void* optval,
socklen_t optlen);
其中:
text
sockfd
↓
修改哪个 Socket
level
↓
选项属于哪一层
optname
↓
具体哪个选项
optval
↓
选项值
optlen
↓
选项值长度
8.3 常见 Socket 选项
SOL_SOCKET
常见:
text
SO_REUSEADDR
↓
允许地址在特定情况下快速重新绑定,
开发服务器快速重启时经常使用
SO_RCVBUF
↓
接收缓冲区大小
SO_SNDBUF
↓
发送缓冲区大小
SO_KEEPALIVE
↓
开启 TCP Keepalive 检测
SO_LINGER
↓
控制 close 时未发送数据的处理方式
SO_DEBUG
↓
Socket 调试相关
SO_OOBINLINE
↓
带外数据相关
还有:
text
SO_BROADCAST
用于允许广播发送,主要和 UDP 数据报场景有关。
IPPROTO_TCP
TCP 特有选项例如:
text
TCP_NODELAY
↓
关闭 Nagle 算法,
需要更低小包延迟时可能使用
TCP_MAXSEG
↓
TCP 最大报文段相关设置
IPPROTO_IP
例如:
text
IP_MULTICAST_IF
IP_MULTICAST_TTL
IP_ADD_MEMBERSHIP
IP_DROP_MEMBERSHIP
主要用于:
text
IP 多播
这种场景更加常见于 UDP。
本篇不继续展开。
现在只需要建立:
Socket 行为不是固定死的,内核提供了大量 Socket Option 允许程序调整。
总结
这一篇真正把 TCP Socket 编程的主线完整走通了。
最基础的 TCP Server:
text
socket()
↓
bind()
↓
listen()
↓
accept()
↓
read() / write()
TCP Client:
text
socket()
↓
connect()
↓
read() / write()
其中一定要区分:
text
listen_sock
↓
监听、获取新连接
service_sock
↓
真正和某一个客户端通信
一个服务器如果同时连接三个 Client:
text
1 个监听 Socket
+
3 个通信 Socket
单进程版本:
text
accept
↓
Service
↓
Service 不退出
↓
无法继续 accept
所以必须让:
text
获取连接
和:
text
处理连接
进入不同执行流。
第一种:
text
多进程
父进程
↓
accept
子进程
↓
Service
然后又需要解决:
text
子进程退出
↓
僵尸进程
因此学习了:
text
waitpid + WNOHANG
SIGCHLD 信号处理
SIGCHLD -> SIG_IGN
双重 fork
进一步:
text
多线程
变成:
text
主线程
↓
accept
工作线程
↓
Service
通过:
text
ThreadData
将:
text
sock
IP
Port
this
一起传给线程。
再使用:
cpp
pthread_detach();
让工作线程自动回收。
继续优化:
text
来一个连接
↓
创建一个线程
变成:
text
程序启动
↓
提前创建 Worker
↓
accept
↓
Task Queue
↓
线程池处理
这又重新回到了以前学习过的:
text
生产者消费者模型
随后将 Server:
text
脱离控制终端
+
长期后台运行
就得到了:
text
守护进程
这一过程中又把 Linux 进程体系串了起来:
text
进程
↓
进程组
↓
会话
↓
控制终端
经典守护进程流程:
text
fork
↓
setsid
↓
再次 fork
↓
chdir
↓
umask
↓
处理文件描述符
而 Linux 已经提供:
cpp
daemon();
帮助我们直接完成常见守护进程化操作。
最后再从内核重新理解 TCP:
text
connect()
↓
通知客户端 TCP 主动建立连接
三次握手
↓
双方操作系统 TCP 协议栈完成
accept()
↓
应用层取走已经建立好的连接
所以:
text
connect 成功
≠
Server 已经执行 accept
也解释了为什么:
text
单进程 Server 忙着服务 A
时:
text
B 的 connect
仍然可能成功。
TCP 通信本身:
text
应用层 buff
↓
write
↓
Socket 发送缓冲区
↓
TCP 自己决定什么时候发送
网络
↓
TCP 接收缓冲区
↓
read
↓
应用层 buff
所以:
text
write/read
本质上首先是:
用户空间和内核 Socket 缓冲区之间的数据拷贝。
两边又分别具有发送和接收方向,因此 TCP 能够实现:
text
全双工
但 TCP 是:
text
面向字节流
所以:
text
一次 write
≠
一次 read
真正的网络程序还必须继续解决:
如何从连续字节流中识别出一条完整消息?
这就需要:
text
应用层协议
而这也正是后面网络编程要继续解决的问题。
最后把这一篇压成一条复习链:
text
UDP 已会
↓
TCP 多了"连接"
↓
Server:socket -> bind -> listen -> accept
↓
Client:socket -> connect
↓
双方 read/write
↓
监听 Socket 和通信 Socket 必须分开
↓
单进程无法同时处理多个长连接
↓
多进程
↓
子进程回收
↓
多线程
↓
线程池
↓
守护进程
↓
连接其实由内核 TCP 维护
↓
connect 触发建连,accept 只是拿结果
↓
TCP 发送接收依赖 Socket 缓冲区
↓
TCP 全双工
↓
TCP 是字节流
↓
下一步:应用层协议
如果这一整条能够顺着说下来,那么 TCP Socket 编程这一篇的核心知识就真正串起来了。