目录
TCP网络程序
服务端初始化
创建套接字
服务端绑定
服务端监听
服务端启动
服务端获取连接
服务端处理请求
客户端初始化
客户端启动
发起连接
发起请求
网络测试
多进程版TCP网络程序
捕捉SIGCHLD信号
孙子进程提供服务
多线程版TCP网络程序
线程池版TCP网络程序
地址转换函数
字符串IP转整数IP
整数IP转字符串IP
inet_ntoa函数问题
TCP网络程序
服务端初始化
创建套接字
TCP服务器在调用socket函数创建套接字时,参数设置如下:
- 协议家族选择AF_INET,进行网络通信
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务
- 协议类型默认设置为0即可
若创建套接字后获得的文件描述符是小于0,则套接字创建失败,此时就没必要进行后续操作,直接终止程序即可
class TcpServer
{
public:
void InitSrever();
~TcpServer();
private:
int _socket_fd;
};
void TcpServer::InitSrever()
{
//创建套接字
_socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if(_socket_fd < 0) {
cerr << "socket fail" << endl;
exit(1);
}
cout << "socket success" << endl;
}
TcpServer::~TcpServer() { if(_socket_fd >= 0) close(_socket_fd); }
- TCP服务器创建套接字的做法与UDP服务器基本一致,不过创建套接字时TCP使用的是流式服务,而UDP使用的是用户数据报服务
- 当析构服务器时,可将服务器对应的文件描述符进行关闭
服务端绑定
套接字创建完毕后只是在系统层面上打开了一个文件,该文件并没有与网络关联,因此创建完套接字后还需要调用bind函数进行绑定操作
绑定的步骤如下:
- 定义struct sockaddr_in结构体变量,将服务器网络相关的属性信息填充到该变量中,如协议家族、IP地址、端口号等
- 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons()函数将端口号由主机序列转为网络序列
- 在设置服务器的IP地址时,可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信
- 若使用的是云服务器,那么在设置服务器的IP地址时,不需要绑定固定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器可以从本地任何一张网卡中读取数据。INADDR_ANY本质是0,因此在设置时不需要进行网络字节序的转换
- 填充完服务器网络相关的属性信息后,调用bind函数进行绑定。绑定实际就是将文件与网络关联,若绑定失败没必要进行后续操作,直接终止程序即可
TCP服务器初始化时需要服务器的端口号,因此在服务器类中需要引入端口号,当实例化服务器对象时就需传入一个端口号。而由于当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不绑定公网IP地址,直接绑定INADDR_ANY即可,因此下面代码中没有在服务器类中引入IP地址
class TcpServer
{
public:
TcpServer(uint16_t port):_socket_fd(-1),_server_port(port) {}
void InitSrever();
~TcpServer();
private:
int _socket_fd;
uint16_t _server_port;
};
void TcpServer::InitSrever()
{
//创建套接字
_socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if(_socket_fd < 0) {
cerr << "socket fail" << endl;
exit(1);
}
cout << "socket success" << endl;
//绑定
struct sockaddr_in local;
memset(&local, '\0', sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(_server_port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_socket_fd, (struct sockaddr*)&local, sizeof local) < 0) {
cerr << "bind fail" << endl;
exit(2);
}
cout << "bind success" << endl;
}
服务端监听
UDP服务器的初始化操作只有两步,创建套接字和绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态
int listen(int sockfd, int backlog);
- sockfd:需要设置为监听状态的套接字对应的文件描述符
- backlog:全连接队列的最大长度。若有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可
返回值:监听成功返回0,监听失败返回-1,同时errno被设置
若监听失败没必要进行后续操作,因为监听失败意味着TCP服务器无法接收客户端发来的连接请求,直接终止程序即可
void TcpServer::InitSrever()
{
//创建套接字
_socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if(_socket_fd < 0) {
cerr << "socket fail" << endl;
exit(1);
}
cout << "socket success" << endl;
//绑定
struct sockaddr_in local;
memset(&local, '\0', sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(_server_port);
local.sin_addr.s_addr = INADDR_ANY;
if(bind(_socket_fd, (struct sockaddr*)&local, sizeof local) < 0) {
cerr << "bind fail" << endl;
exit(2);
}
cout << "bind success" << endl;
//设置服务器监听状态
if(listen(_socket_fd, BACKLOG) < 0) {
cerr << "listen fail" << endl;
exit(3);
}
cout << "listen success" << endl;
}
class TcpServer
{
public:
TcpServer(uint16_t port):_socket_listen_fd(-1),_server_port(port) {}
void InitSrever();
~TcpServer();
private:
int _socket_listen_fd;
uint16_t _server_port;
};
- 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该被称为监听套接字。为了表明寓意,将代码中套接字的名字由_socket_fd改为_socket_listen_fd
- 初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成
服务端启动
服务端获取连接
TCP服务器初始化后即可开始运行,但TCP服务端在与客户端网络通信前,服务端需先获取到客户端的连接请求
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等,输出型参数
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数
返回值:获取连接成功则返回接收到的套接字的文件描述符,失败返回-1,同时错误码被设置
调用accept函数获取连接时,是从监听套接字中获取的。若accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符
监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字中获取新连接
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字
服务端获取连接代码实现
- accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应继续获取连接
- 若要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP(转为主机序列),调用ntohs函数将端口号由网络序列转换成主机序列
- inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP
void TcpServer::StartUp()
{
//获取连接
while(true)
{
struct sockaddr_in foreign;
memset(&foreign, '\0', sizeof foreign);
socklen_t length = sizeof foreign;
int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length);
if(server_socket_fd < 0) {
cerr << "accept fail" << endl;
continue;
}
string client_ip = inet_ntoa(foreign.sin_addr);
uint16_t client_port = ntohs(foreign.sin_port);
cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl;
}
}
服务端处理请求
TCP服务器已能够获取连接请求,接下来要对获取到的连接进行处理。为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为"服务套接字"
为了让通信双方都能看到对应的现象,下面实现一个回声TCP服务器,服务端在为客户端提供服务时将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了
read函数
ssize_t read(int fd, void *buf, size_t count);
- fd:特定的文件描述符,表示从该文件描述符中读取数据
- buf:数据的存储位置,表示将读取到的数据存储到该位置
- count:数据的个数,表示从该文件描述符中读取数据的字节数
- 若返回值大于0,则表示本次实际读取到的字节数
- 若返回值等于0,则表示对端已将连接关闭
- 若返回值小于0,则表示读取时出现错误
若客户端将连接关闭了,那么此时服务端将套接字中的信息读完后就会读取到0,因此若服务端调用read函数后的返回值为0,此时服务端就不必再为该客户端提供服务了
write函数
ssize_t write(int fd, const void *buf, size_t count);
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字
- buf:需要写入的数据
- count:需要写入数据的字节个数
写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端
服务端读取数据是服务套接字中读取的,写入数据的时候也是写入进服务套接字。服务套接字既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现
在从服务套接字中读取客户端发来的数据时,若调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,若一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏
void TcpServer::StartUp()
{
while(true)
{
//获取连接
struct sockaddr_in foreign;
memset(&foreign, '\0', sizeof foreign);
socklen_t length = sizeof foreign;
int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length);
if(server_socket_fd < 0) {
cerr << "accept fail" << endl;
continue;
}
string client_ip = inet_ntoa(foreign.sin_addr);
uint16_t client_port = ntohs(foreign.sin_port);
cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl;
//处理客户端请求
Service(server_socket_fd, client_ip, client_port);
}
}
void TcpServer::Service(int server_socket_fd, string client_ip, uint16_t client_port)
{
char buffer[BUFF_SIZE];
while(true)
{
ssize_t size = read(server_socket_fd, buffer, sizeof(buffer) - 1);
if(size > 0) //读取成功
{
buffer[size] = '\0';
cout << buffer << endl;
write(server_socket_fd, buffer, size);
}
else if(size == 0) //对端关闭连接
{
cout << client_ip << ":" << client_port << " close" << endl;
break;
}
else //读取失败
{
cerr << server_socket_fd << " read error" << endl;
break;
}
}
close(server_socket_fd);
cout << client_ip << ":" << client_port << "server done" << endl;
}
客户端初始化
客户端不需要进行绑定和监听:
- 服务端要进行绑定是因为服务端的IP地址和端口号不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要程序员手动进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端
- 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的
客户端必须要知道要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信
class TcpClient
{
public:
TcpClient(string ip, uint16_t port):_socket_fd(-1),_server_ip(ip),_server_port(port) {}
void InitClient();
~TcpClient();
private:
int _socket_fd;
string _server_ip;
uint16_t _server_port;
};
void TcpClient::InitClient()
{
//创建套接字
_socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if(_socket_fd < 0) {
cerr << "socket fail" << endl;
exit(1);
}
}
TcpClient::~TcpClient() { if(_socket_fd >= 0) close(_socket_fd); }
客户端启动
发起连接
客户端不需要绑定,也不需要监听,客户端创建完套接字后可直接向服务端发起连接请求
connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:特定的套接字,表示通过该套接字发起连接请求
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等
- addrlen:传入的addr结构体的长度
返回值:连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置
客户端不是不需要进行绑定,而是不需要程序员手动进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个空闲端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。若connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器
调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求
void TcpClient::StartUp()
{
//发起连接
struct sockaddr_in server;
memset(&server, '\0', sizeof server);
server.sin_family = AF_INET;
server.sin_addr.s_addr = inet_addr(_server_ip.c_str());
server.sin_port = htons(_server_port);
if(connect(_socket_fd, (struct sockaddr*)&server, sizeof(server)) == 0) {
cout << "connect success" << endl;
Request(); //发起请求
}
else {
cerr << "connect fail" << endl;
exit(2);
}
}
发起请求
当客户端连接到服务端后,客户端就可以向服务端发送数据了,可以让客户端将用户输入的数据发送给服务端,发送时调用send函数向套接字当中写入数据即可
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用recv函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误
void TcpClient::Request()
{
char buffer[BUFF_SIZE];
string message;
while(true)
{
cout << "Pleses Entre#";
getline(cin, message);
send(_socket_fd, message.c_str(), message.size(), 0);
ssize_t size = recv(_socket_fd, buffer, sizeof(buffer) - 1, 0);
if (size > 0){
buffer[size] = '\0';
cout << "server echo# " << buffer << endl;
}
else if (size == 0) {
cout << "server close!" << endl;
break;
}
else {
cerr << "read error!" << endl;
break;
}
}
}
通过服务端的IP地址和端口号即可构造出一个客户端对象
void Usage(std::string proc)
{
cout << "Usage: " << proc << "server_ip server_port" << endl;
}
int main(int argc, char* argv[])
{
if (argc != 3) {
Usage(argv[0]);
exit(1);
}
string server_ip = argv[1];
int server_port = atoi(argv[2]);
TcpClient* client = new TcpClient(server_ip, server_port);
client->InitClient();
client->StartUp();
return 0;
}
网络测试
服务端和客户端均已编写完毕,下面进行网络测试。测试时先启动服务端,然后使用 netstat 命令进行查看,此时能看到 ./tcpserverd 服务进程,该进程当前处于监听状态
然后再通过 ./tcpclient IP号 端口号 的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务
当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息
若此时客户端退出了,那么服务端调用read函数时返回值就为0,此时服务端就知道客户端退出了,进而会终止对该客户端的服务。
多进程版TCP网络程序
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接对应的客户端提供服务,而是当前执行流调用fork函数创建子进程,子进程为父进程获取到的连接提供服务
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字中获取新连接,而不用关心获取上来的连接对应的客户端是否服务完毕
子进程继承父进程的文件描述符表
文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。如父进程打开了一个文件,该文件对应的文件描述符是3,子进程的3号文件描述符也会指向这个打开的文件,若子进程再创建一个子进程,那么孙子进程的3号文件描述符也同样会指向这个打开的文件
当父进程创建子进程后,父子进程之间保持独立性,此时父进程文件描述符表的变化不会影响子进程。譬如,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务
等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待
阻塞式等待与非阻塞式等待:
若服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务
若服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出,且编码较为复杂
服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时可以考虑让服务端不等待子进程退出的方案
捕捉SIGCHLD信号
当子进程退出时会给父进程发送SIGCHLD信号,若父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程可以继续从监听套接字中获取新连接
void TcpServer::StartUp()
{
//设置忽略SIGCHLD信号
signal(SIGCHLD, SIG_IGN);
while(true)
{
//获取连接
struct sockaddr_in foreign;
memset(&foreign, '\0', sizeof foreign);
socklen_t length = sizeof foreign;
int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length);
if(server_socket_fd < 0) {
cerr << "accept fail" << endl;
continue;
}
string client_ip = inet_ntoa(foreign.sin_addr);
uint16_t client_port = ntohs(foreign.sin_port);
cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl;
//处理客户端请求
pid_t id = fork();
if(id == 0) { //child
Service(server_socket_fd, client_ip,client_port);
exit(4);
}
}
}
网络测试
重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控
while :; do ps axj | head -1 && ps axj | grep tcpserverd | grep -v grep;echo "######################";sleep 1;done
此时启动一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务
若再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,为这个客户端提供服务
孙子进程提供服务
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程
- 爸爸进程:爷爷进程调用fork函数创建出来的进程
- 孙子进程:爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务
爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此时孙子进程变成孤儿进程被1号进程领养,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。不需要处理孙子进程,其资源由系统释放
关闭对应的文件描述符
服务进程(爷爷进程)调用accept函数获取到新连接后,会让孙子进程为该连接对应的服务端提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程
而父子进程创建后,其各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭
对于爸爸进程和孙子进程来说,是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此服务进程可以将监听套接字关掉
对于服务进程来说,调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),若服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少
对于孙子进程而言,还是建议关闭从服务进程继承下来的监听套接字。实际就算不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某些误操作,此时就会对监听套接字当中的数据造成影响
实际编码时,在爸爸进程fork之前将其监听套接字关闭,孙子进程继承的文件描述符表中自然没有监听套接字了
多线程版TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现
服务进程调用accept函数获取到一个新连接后,可创建一个线程,让该线程为对应客户端提供服务
主线程创建出新线程后,也是需要等待新线程退出回收资源的,否则也会造成资源浪费的问题。但对于线程来说,若不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端
各个线程共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表
当主线程调用accept函数获取到一个文件描述符后,新线程是能够直接访问这个文件描述符的
虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道其所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符,即告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作
参数结构体
实际新线程在为客户端提供服务时调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数
这时可以设计一个参数结构体ThreadDate,这三个参数可以存放到ThreadDate结构体中,当主线程创建新线程时就可以定义一个ThreadDate对象,将客户端对应的套接字、IP地址和端口号设置进这个ThreadDate对象中,然后将Param对象的地址作为新线程执行例程的参数进行传入
此时新线程在执行例程当中再将这个void*类型的参数强转为Param*类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务
class ThreadDate
{
public:
ThreadDate(int fd, string ip,uint16_t port):_server_socket_fd(fd),_client_ip(ip),_client_port(port) {}
~ThreadDate() {}
public:
int _server_socket_fd;//accept获取连接得到文件描述符,用于服务
string _client_ip;
uint16_t _client_port;
};
文件描述符关闭的问题
所有线程看到的都是同一张文件描述符表,因此当某个线程要对文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
- 对于主线程accept来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了
Service函数定义为静态成员函数
由于调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。若要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针
在线程的执行例程中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此需要将Service函数定义为静态成员函数,恰好Service函数内部进行的操作都不涉及类内数据的修改,因此直接在Service函数前面加上一个static即可
class TcpServer
{
public:
TcpServer(uint16_t port):_socket_listen_fd(-1),_server_port(port) {}
void InitServer();
void StartUp();
static void Service(int, string, uint16_t);
~TcpServer();
private:
int _socket_listen_fd;
uint16_t _server_port;
};
void TcpServer::StartUp()
{
while(true)
{
//获取连接
struct sockaddr_in foreign;
memset(&foreign, '\0', sizeof foreign);
socklen_t length = sizeof foreign;
int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length);
if(server_socket_fd < 0) {
cerr << "accept fail" << endl;
continue;
}
string client_ip = inet_ntoa(foreign.sin_addr);
uint16_t client_port = ntohs(foreign.sin_port);
cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl;
//处理客户端请求
ThreadDate* ptr = new ThreadDate(server_socket_fd, client_ip, client_port);
pthread_t thread_id;
pthread_create(&thread_id, nullptr, HandlerClient, (void*)ptr);
/*应将ThreadDate数据开辟在堆区,若开辟在主线程栈区,主线程循环accept并处理客户端请求时,会修改TheadDate内数据*/
}
}
void* TcpServer::HandlerClient(void* args)
{
pthread_detach(pthread_self());//线程分离,资源由系统回收
ThreadDate* ptr = (ThreadDate*)args;
Service(ptr->_server_socket_fd, ptr->_client_ip, ptr->_client_port);
delete ptr;
return nullptr;
}
监控时使用的不再是 ps -axj 命令,而是 ps -aL 命令
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
启动服务端,通过监控发现此时只有一个服务线程(主线程),现在在等待客户端的连接请求
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控中显示了两个线程
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时就有了三个线程
由于为这两个客户端提供服务的是两个不同的执行流,因此这两个客户端可同时享受服务端提供的服务,发送给服务端的消息都能够在服务端打印,并且这两个客户端都能够收到服务端的回显数据
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下主线程在等待新连接的到来
线程池版TCP网络程序
单纯多线程存在的问题
- 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样不仅麻烦,且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程
- 若有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也体验也会变差
解决方案
- 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程
- 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,若当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒
- 服务端创建的这一批线程的数量不能太多,CPU的压力也就不会太大。此外,若有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务
引入线程池
要解决问题就需在服务端引入线程池,线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,还能够保证内核充分利用,防止过分调度(调度周期过长)
在线程池中存在一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池中,在线程池中默认创建10个线程,这些线程不断检测任务队列中是否有任务,若有任务就取出任务,然后调用该任务对应的Run函数对该任务进行处理,若线程池中没有任务当前线程就会进入休眠状态
服务类新增线程池成员
服务端引入线程池,因此在服务类中需要新增一个指向线程池的指针成员:
- 在构造线程池对象时可以指定线程池中线程的个数,此时默认线程的个数为10
- 构造线程池时,线程池中的若干线程就会创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列中取出任务进行处理
- 当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列
实际上就是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池中的若干线程就不断从任务队列当中获取任务进行处理,承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池中的任务队列
class TcpServer
{
public:
TcpServer(uint16_t port):_socket_listen_fd(-1),_server_port(port),_thread_pool(ThreadPool<Task>::GetThreadPool()) {}
void InitServer();
void StartUp();
static void* HandlerClient(void*);
static void Service(int, string, uint16_t);
~TcpServer();
private:
int _socket_listen_fd;
uint16_t _server_port;
unique_ptr<ThreadPool<Task>> _thread_pool;
};
void TcpServer::StartUp()
{
_thread_pool->Run();//启动线程池
while(true)
{
//获取连接
struct sockaddr_in foreign;
memset(&foreign, '\0', sizeof foreign);
socklen_t length = sizeof foreign;
int server_socket_fd = accept(_socket_listen_fd, (struct sockaddr*)&foreign, &length);
if(server_socket_fd < 0) {
cerr << "accept fail" << endl;
continue;
}
string client_ip = inet_ntoa(foreign.sin_addr);
uint16_t client_port = ntohs(foreign.sin_port);
cout << "New Link: [" << server_socket_fd << "] [" << client_ip << "] [" << client_port << "]" << endl;
//构造任务并推送到任务队列中
Task task(server_socket_fd, client_ip, client_port, Service);
_thread_pool->PushTask(task);
}
}
设计任务类
该任务类中需要包含accept客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个
任务类中需包含一个仿函数方法,当线程池中的线程取到任务后就会直接调用仿函数对该任务进行处理,而实际处理这个任务的方法是服务类中的Service函数,服务端就是通过调用Service函数为客户端提供服务的
typedef void(*fun_t)(int, std::string, uint16_t);
class Task
{
public:
Task() {}
Task(int sock, std::string client_ip, int client_port, fun_t handler) : _server_socket_fd(sock)
, _client_ip(client_ip), _client_port(client_port), _handler(handler) {}
//任务处理函数
void operator()(const std::string& name) {
_handler(_server_socket_fd, _client_ip, _client_port);
}
private:
int _server_socket_fd;
std::string _client_ip;
uint16_t _client_port;
fun_t _handler;
};
实际可以让服务器处理不同的任务,当前服务器只是在进行字符串的回显处理,而实际要怎么处理这个任务完全是由任务类中的_handler成员来决定的
若想要让服务器处理其他任务,只需要修改()的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这被称为把通信功能和业务逻辑在软件上做解耦
网络测试
运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了11个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池中为客户端提供服务的线程
当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后放入任务队列,此时线程池中的10个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务
无论有多少客户端发来请求,在服务端都只会有线程池中的10个线程为之提供服务,线程池中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出
地址转换函数
字符串IP转整数IP
inet_aton函数
int inet_aton(const char *cp, struct in_addr *inp);
- cp:待转换的字符串IP。
- inp:转换后的整数IP,输出型参数
返回值:若转换成功则返回一个非零值,若输入的地址不正确则返回零值
inet_addr函数
in_addr_t inet_addr(const char *cp);
参数cp:待转换的字符串IP
返回值:若输入地址有效,则返回转换后的整数IP;若无效,则返回INADDR_NONE(-1)
inet_pton函数
int inet_pton(int af, const char *src, void *dst);
- af参数:协议家族
- src参数:待转换的字符串IP
- dst参数:转换后的整数IP,输出型参数
返回值说明:
- 若转换成功,则返回1
- 若输入的字符串IP无效,则返回0
- 若输入的协议家族af无效,则返回-1,并将errno设置为EAFNOSUPPORT
整数IP转字符串IP
inet_ntoa函数
char *inet_ntoa(struct in_addr in);
参数in:待转换的整数IP
返回值:返回转换后的字符串IP
inet_ntop函数
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
- af参数:协议家族
- src参数:待转换的整数IP
- dst参数:转换后的字符串IP,输出型参数
- size参数:用于指明dst中可用的字节数
返回值:若转换成功,则返回一个指向dst的非空指针;若转换失败,则返回NULL。
- 最常用的两个转换函数是inet_addr和inet_ntoa,因为这两个函数足够简单。这两个函数的参数就是需要转换的字符串IP或整数IP,而这两个函数的返回值就是对应的整数IP和字符串IP
- 其中inet_pton和inet_ntop函数不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此这两个函数中对应的参数类型是void*
- 转换函数都是为了满足某些打印场景的或者做某些数据分析,如网络安全方面的数据分析
inet_ntoa函数问题
inet_ntoa函数可以将4字节的整数IP转换成字符串IP,其中该函数返回的这个转换后的字符串IP是存储在静态存储区的,不需要调用者手动进行释放。若多次调用inet_ntoa函数,此时就会出现数据覆盖的问题
inet_ntoa函数内部只在静态存储区申请了一块区域,导致inet_ntoa函数第二次转换的结果就会覆盖第一次转换的结果
若要多次调用inet_ntoa函数,那么就要及时保存inet_ntoa的转换结果。