文章目录
目录
[1. 服务器创建套接字](#1. 服务器创建套接字)
[2. 服务器绑定](#2. 服务器绑定)
[3. 服务器监听](#3. 服务器监听)
[4. 服务器获取连接](#4. 服务器获取连接)
[5. 服务器处理请求](#5. 服务器处理请求)
[6. 客户端创建套接字](#6. 客户端创建套接字)
[7. 客户端连接服务器](#7. 客户端连接服务器)
[8. 客户端发起请求](#8. 客户端发起请求)
[9. 服务器测试](#9. 服务器测试)
[10. 单执行流服务器弊端](#10. 单执行流服务器弊端)
[1. 捕捉SIGCHLD信号](#1. 捕捉SIGCHLD信号)
[2. 孙子进程提供服务](#2. 孙子进程提供服务)
[1. 添加词典功能](#1. 添加词典功能)
[2. 修补](#2. 修补)
[1. 会话](#1. 会话)
[1. 会话的定义](#1. 会话的定义)
[2. 会话的组成](#2. 会话的组成)
[2. 会话管理](#2. 会话管理)
[3. 守护进程化](#3. 守护进程化)
[4. daemon库函数](#4. daemon库函数)
一、简单的TCP网络程序
1. 服务器创建套接字
我们将TCP服务器封装成一个类,当我们定义出一个服务器对象后需要马上对服务器进行初始化,而初始化TCP服务器要做的第一件事就是创建套接字。
TCP服务器在调用socket函数创建套接字时,参数设置如下:
- 协议家族选择AF_INET,因为我们要进行的是网络通信。
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
- 协议类型默认设置为0即可。
如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。
cpp
class TcpServer
{
public:
void InitServer()
{
// 创建套接字
_sockfd= socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
_log(Fatal, "socket create fail, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
_log(Info, "socket create success, _sockfd: %d", _sockfd);
}
~TcpServer()
{
if (_sockfd>= 0){
close(_sockfd);
}
}
private:
int _sockfd;
uint16_t _port;
std::string _ip;
};
说明一下:
- 实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。
- 当析构服务器时,可以将服务器对应的文件描述符进行关闭。
2. 服务器绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用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地址。
cpp
class TcpServer
{
public:
TcpServer(const uint16_t& port, const std::string& ip = defaultip)
:_sockfd(defaultfd), _ip(ip), _port(port)
{}
void InitServer()
{
// 创建套接字
_sockfd= socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
_log(Fatal, "socket create fail, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
_log(Info, "socket create success, _sockfd: %d", _sockfd);
// sockaddr_in 结构体初始化
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_aton(_ip.c_str(), &(local.sin_addr)); //用一下 inet_aton 接口
// bind
socklen_t len = sizeof(local);
if (bind(_sockfd, (const struct sockaddr*)&local, len) < 0)
{
_log(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BIND_ERR);
}
_log(Info, "bind socket success");
}
~TcpServer()
{
if (_sockfd>= 0){
close(_sockfd);
}
}
private:
int _sockfd;
uint16_t _port;
std::string _ip;
};
说明一下:
- TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别。
3. 服务器监听
UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
将套接字设置为listen状态,表示该套接字未来随时随地等待新连接的到来。
listen
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
cpp
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
服务器监听
TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可。
cpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h> //以上四个头文件是写套接字必需的
#include "Log.hpp"
const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";
const int backlog = 10; // 一般不要设置太大
Log _log;
enum{
Usage_ERR=1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
class TcpServer
{
public:
// 构造函数内尽量不妨有风险的事情
TcpServer(const uint16_t& port, const std::string& ip = defaultip)
:_listensock(defaultfd), _ip(ip), _port(port)
{}
// 尽量将简单的初始化放在构造函数内,将有风险的东西放在独立函数内,例如创建套接字是可能失败的
// 在代码出异常时,不会影响对象的创建,只用在独立的函数内处理
void InitServer()
{
// 创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
_log(Fatal, "socket create fail, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
_log(Info, "socket create success, _listensock: %d", _listensock);
// sockaddr_in 结构体初始化
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_aton(_ip.c_str(), &(local.sin_addr)); //用一下 inet_aton 接口
// bind
socklen_t len = sizeof(local);
if (bind(_listensock, (const struct sockaddr*)&local, len) < 0)
{
_log(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BIND_ERR);
}
_log(Info, "bind socket success");
// Tcp是面向连接的,服务器一般比较被动
// 等待请求,建立连接
if (listen(_listensock, backlog) < 0)
{
_log(Fatal, "listen error, errno: %d, errstring: %s", errno, strerror(errno));
exit(LISTEN_ERR);
}
_log(Info, "listen socket success");
}
~TcpServer()
{
if (_listensock >= 0)
{
close(_listensock);
}
}
private:
int _listensock;
uint16_t _port;
std::string _ip;
};
说明一下:
- 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock改为listen
socket。 - 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。
4. 服务器获取连接
TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。
accept
获取连接的函数叫做accept,该函数的函数原型如下:
cpp
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函数返回的套接字,而不是监听套接字。
服务端获取连接
服务端在获取连接时需要注意:
- accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
- 如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
- inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。
cpp
class TcpServer
{
public:
void Start()
{
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
// accept等待连接
int sockfd = accept(_listensock, (struct sockaddr*)&client, &len);
if (sockfd < 0)
{
_log(Fatal, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 获取client的IP、prot信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
_log(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
// 处理客户信息
Service(sockfd, clientip, clientport);
// 关闭sockfd文件
close(sockfd);
}
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
};
5. 服务器处理请求
现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。但此时为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为"服务套接字"。
为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
read函数
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
cpp
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
read返回值为0表示对端连接关闭
这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
write函数
TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
cpp
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
服务端处理请求
需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
cpp
class TcpServer
{
public:
void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
{
// 读取缓冲区
char buffer[4096];
while (true)
{
// 读取数据
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "cilent say# " << buffer << std::endl;
// 回写数据
std::string echo_string = "TcpServer echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if (n == 0)
{
_log(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
_log(Warning, "read error, errno: %d, errstring: %s", errno, strerror(errno));
break;
}
}
}
private:
int _listen_sock; //监听套接字
int _port; //端口号
};
6. 客户端创建套接字
客户端不需要进行绑定和监听:
- 服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要我们进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端。
- 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。
- Tcp、Udp客户端都需要bind,但是不需要显示的bind,系统会自动bind,选择随机端口号。Udp在第一次发送数据时bind,tcp在客户端发起connet的时候自动bind
此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。
cpp
#include <iostream>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;
void Usage(const std::string& proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
// 用法说明
Usage(argv[0]);
exit(1);
}
// 命令行参数获取服务器IP、port
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
struct sockaddr_in server;
bzero(&server, 0);
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
// 连接服务器
// 发送信息
return 0;
}
7. 客户端连接服务器
由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。
connect
发起连接请求的函数叫做connect,该函数的函数原型如下:
cpp
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
客户端连接服务器
需要注意的是,客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。
此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。
cpp
// 连接服务器
int n = connect(sockfd, (const struct sockaddr*)&server, sizeof(server));
if (n < 0)
{
cerr << "connect error" << endl;
return 2;
}
8. 客户端发起请求
由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误
cpp
// 发送信息
std::string message;
while (true)
{
cout << "Please Enter# ";
getline(cin, message);
write(sockfd, message.c_str(), message.size());
char inbuffer[4096];
int n = read(sockfd, inbuffer, sizeof(inbuffer));
if (n > 0)
{
inbuffer[n] = 0;
cout << inbuffer << endl;
}
}
9. 服务器测试
现在服务端和客户端均已编写完毕,下面我们进行测试。测试时我们先启动服务端,然后通过netstat
命令进行查看,此时我们就能看到一个名为 tcpserver 的服务进程,该进程当前处于监听状态。
然后再通过./tcp_client IP地址 端口号
的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务。
如果此时客户端退出了,那么服务端在调用 read 函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务。
注意: 此时是服务端对该客户端的服务终止了,而不是服务器终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求。
10. 单执行流服务器弊端
当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。
单执行流的服务器
通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
因为我们的代码是单进程的,Service函数是死循环,只有该客户的服务完成后才会执行别人的服务
客户端为什么会显示连接成功?
因为服务器一直在listen,当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过单执行流的服务端没有机会调用 accept 函数将该连接获取上来。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过 listen 函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
如何解决?
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程。
二、多进程版的TCP网络程序
我们可以将当前的单执行流服务器改为多进程版的服务器。
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。
子进程继承父进程的文件描述符表
需要注意的是,文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。
但当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
简单的多进程只用在Start开始处,for循环创建多个子进程,子进程会自动往下执行while循环,但是它们会竞争式的抢占acccept,所以还需要信号量加锁,比较麻烦,所以我们有另外的巧妙做法
等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用 wait 或 waitpid 函数对子进程进行等待。
阻塞式等待与非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待子进程服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的单执行流的方式为客户端提供服务。
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。
不等待子进程退出的方式
让父进程不等待子进程退出,常见的方式有两种:
- 捕捉SIGCHLD信号 ,将其处理动作设置为忽略。
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。
创建孙子进程非常巧妙,因为子进程在创建它的子进程之后,我们直接让子进程退出,那么此时的孙子进程就会因没有人回收它而称为孤儿进程,而所有的孤儿进程都会被OS(1号进程)领养,所以就很巧妙的解决了不等待子进程退出
1. 捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了
该方式实现起来非常简单,也是比较推荐的一种做法。
cpp
void Start()
{
signal(SIGCHLD, SIG_IGN);
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
// accept等待连接
int sockfd = accept(_listensock, (struct sockaddr*)&client, &len);
if (sockfd < 0)
{
_log(Fatal, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 获取client的IP、prot信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
_log(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
pid_t id = fork();
if (id == 0)
{
// 子进程把不需要的fd关掉,避免误写
close(_listensock);
Service(sockfd, clientip, clientport);
close(sockfd);
exit(0);
}
// 交给子进程之后再关闭sockfd文件,如果不关闭,那么会导致fd表空间越用越少
close(sockfd);
}
}
- 对于子进程来说sockfd可关可不关,但是建议关掉,因为子进程退出,进程的资源自动被释放,被打开的文件也会关闭,但是对于父进程来说,sockfd一定要关上,因为如果不断地accept ,那么父进程进程的文件描述符表终将会被填满,这是不合理的
- 并且注意:父进程关掉sockfd是不影响子进程的信息发送的,因为进程间是独立的,子进程的文件描述符表是独立的,父进程关闭sockfd不影响子进程向sockfd读取和发送信息
测试
此时可以看到,一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。
此时我们运行一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务。
如果再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务。
最重要的是,由于这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端一个个ctrl + c退出后,服务端会读到0,然后在Server函数break出来,然后关闭sockfd文件,子进程exit,所以在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接,listen
2. 孙子进程提供服务
我们也可以让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了。
命名说明:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
- 父亲进程:由爷爷进程调用fork函数创建出来的进程。
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
我们让父亲进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待父亲进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
不需要等待孙子进程退出
而由于父亲进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的。
关闭对应的文件描述符
服务进程(爷爷进程)调用accept函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了父亲进程,而父亲进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程。
而父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭。
同样的,对于父亲进程和孙子进程来说,它们是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此爸爸进程可以将监听套接字关掉。
关闭文件描述符的必要性:
- 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
- 而对于父亲进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
tcpserver
cpp
void Start()
{
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
// accept等待连接
int sockfd = accept(_listensock, (struct sockaddr*)&client, &len);
if (sockfd < 0)
{
_log(Fatal, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 获取client的IP、prot信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
_log(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
pid_t id = fork();
if (id == 0)
{
close(_listensock);
if (fork() > 0) exit(0);
Service(sockfd, clientip, clientport);
close(sockfd);
exit(0);
}
// 关闭sockfd文件
close(sockfd);
waitpid(id, nullptr, 0);
}
}
测试
继续使用监控脚本对服务进程进行实时监控
cpp
while :; do ps axj | head -1 && ps axj | grep tcpserver | grep -v grep;echo "--------------------"; sleep 1;done
此时没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。
此时我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出父亲进程,父亲进程再创建出孙子进程,之后父亲进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程。
当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务
此时这两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。
三、多线程版TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
各个线程共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。
因此当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。
需要注意的是,虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。
参数结构体
实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。
这时我们可以设计一个参数结构体ThreadData,此时这三个参数就可以放到ThreadData结构体当中,当主线程创建新线程时就可以定义一个ThreadData对象,将客户端对应的套接字、IP地址和端口号设计进这个ThreadData对象当中,然后将ThreadData对象的地址作为新线程执行例程的参数进行传入。
此时新线程在执行例程当中再将这个void*类型的参数强转为ThreadData*类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务。
注意:
- Routine放在类内时要加上static,变为静态成员函数,因为普通成员函数默认含有一个this指针参数,而线程创建只能传一个参数,但是Routine内部又需要用到service函数,所以我们可以将this指针也封装在ThreadData结构体内,作为ThreadData结构体的第四个字段,也可以将Service函数单独放在类外,因为我们观察发现Service并没有使用到类内成员
- 因为ThreadData用到了TcpServer的指针,所以先声明一下
- 新线程执行完service函数后,要将sockfd关闭,因为主线程不能关闭sockfd,因为新线程可能正在使用,所以只有新线程执行完之后,由新线程自行关闭
cpp
class TcpServer;
class ThreadData
{
public:
ThreadData(int fd, const std::string& ip, const uint16_t& port, TcpServer* t)
:sockfd(fd), clientip(ip), clientport(port), tsvr(t)
{}
public:
int sockfd;
std::string clientip;
uint16_t clientport;
TcpServer* tsvr;
};
class TcpServer
{
public:
// 构造函数内尽量不妨有风险的事情
TcpServer(const uint16_t& port, const std::string& ip = defaultip)
:_listensock(defaultfd), _ip(ip), _port(port)
{}
// 尽量将简单的初始化放在构造函数内,将有风险的东西放在独立函数内,例如创建套接字是可能失败的
// 在代码出异常时,不会影响对象的创建,只用在独立的函数内处理
void InitServer()
{
// 创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
_log(Fatal, "socket create fail, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
_log(Info, "socket create success, _listensock: %d", _listensock);
// sockaddr_in 结构体初始化
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_aton(_ip.c_str(), &(local.sin_addr)); //用一下 inet_aton 接口
// bind
socklen_t len = sizeof(local);
if (bind(_listensock, (const struct sockaddr*)&local, len) < 0)
{
_log(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BIND_ERR);
}
_log(Info, "bind socket success");
// Tcp是面向连接的,服务器一般比较被动
// 等待请求,建立连接
if (listen(_listensock, backlog) < 0)
{
_log(Fatal, "listen error, errno: %d, errstring: %s", errno, strerror(errno));
exit(LISTEN_ERR);
}
_log(Info, "listen socket success");
}
static void* Routine(void* args)
{
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
close(td->sockfd);
delete td;
return nullptr;
}
void Start()
{
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
// accept等待连接
int sockfd = accept(_listensock, (struct sockaddr*)&client, &len);
if (sockfd < 0)
{
_log(Fatal, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 获取client的IP、prot信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
_log(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
// 2.0 孙子进程
// pid_t id = fork();
// if (id == 0)
// {
// // 子进程把不需要的fd关掉,避免误写
// close(_listensock);
// if (fork() > 0) exit(0);
// Service(sockfd, clientip, clientport);
// close(sockfd);
// exit(0);
// }
// // 交给子进程之后再关闭sockfd文件,如果不关闭,那么会导致fd表空间越用越少
// close(sockfd);
// waitpid(id, nullptr, 0);
// 2.1 信号捕捉
// signal(SIGCHLD, SIG_IGN);
// pid_t id = fork();
// if (id == 0)
// {
// // 子进程把不需要的fd关掉,避免误写
// close(_listensock);
// Service(sockfd, clientip, clientport);
// close(sockfd);
// exit(0);
// }
// // 交给子进程之后再关闭sockfd文件,如果不关闭,那么会导致fd表空间越用越少
// close(sockfd);
// 3.0 多线程
ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
pthread_t tid;
pthread_create(&tid, nullptr, Routine, td);
}
}
void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
{
// 读取缓冲区
char buffer[4096];
while (true)
{
// 读取数据
ssize_t n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "cilent say# " << buffer << std::endl;
// 回写数据
std::string echo_string = "TcpServer echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if (n == 0)
{
_log(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
break;
}
else
{
_log(Warning, "read error, errno: %d, errstring: %s", errno, strerror(errno));
break;
}
}
}
~TcpServer()
{
if (_listensock >= 0)
{
close(_listensock);
}
}
private:
int _listensock;
uint16_t _port;
std::string _ip;
};
文件描述符关闭的问题
由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。
测试
此时我们再重新编译服务端代码,由于代码当中用到了多线程,因此编译时需要携带上-pthread
选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj
命令,而是ps -aL
命令。
cpp
while :; do ps -aL | head -1 && ps -aL | grep tcpserver;echo "-----------------";sleep 1;done
运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控当中显示了两个线程
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程。
由于为这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。
四、线程池版的TCP网络程序
单纯多线程存在的问题
当前多线程版的服务器存在的问题:
- 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。
- 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。
解决
- 使用我们之前封装实现的线程池,来预先创建一定量的线程,等待服务客户端
- 每处理一次客户请求就结束service,而不是一直循环服务,这样就可以解决线程太多问题
引入线程池
实际要解决这里的问题我们就需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。
其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入条件变量下进行休眠。
在博主的另一篇博客当中详细介绍并实现了线程池,这里就直接将线程池的代码接入到当前的TCP服务器,因此下面只会讲解线程池接入的方法,如果对线程池的实现有疑问的可以去阅读那篇博客。
此处为单例模式的线程池
cpp
#pragma once
#include <vector>
#include <queue>
#include <iostream>
#include <pthread.h>
// 线程信息
struct ThreadInfo
{
pthread_t tid; // 线程tid
std::string name; // 线程名字
};
// 线程池
template <class T>
class ThreadPool
{
static const int defaultnum = 5;
public:
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();
}
std::string GetThreadName(pthread_t tid)
{
// 获取线程name
for (const auto &e : _threads)
{
if (e.tid == tid)
return e.name;
}
return "None";
}
public:
// 消费者
static void *HandlerTask(void *args)
{
// 获取this指针
ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
// 获取线程名字,方便后续打印观察
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
// 1. 加锁
tp->Lock();
// 2. 判断是否等待排队(while 防止伪唤醒)
while (tp->IsQueueEmpty())
{
tp->ThreadSleep();
}
// 3. 获取任务
T t = tp->pop();
// 4. 解锁
tp->Unlock();
// 5. 运行任务
t();
}
}
/*
1. 如果HandlerTask是普通成员函数,形参默认有this指针,那么线程创建时还需要额外传递this指针
2. 将HandlerTask改为静态成员函数,可是函数内部需要使用成员函数和成员变量,所以crete时的参数应传递this指针
3. 所以将Lock等函数访问权限设为public
*/
// 线程池启动,创建线程,执行任务
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);
}
}
// 外部push任务
void push(const T &t)
{
// 1. 上锁
Lock();
// 2. push
_tasks.push(t);
// 3. 唤醒线程执行任务
Wakeup();
// 4. 解锁
Unlock();
}
// 在HandlerTask内部调用它时已经加锁了
T pop()
{
T t = _tasks.front();
_tasks.pop();
return t;
}
// 单例模式,获取该对象的指针
static ThreadPool<T> *GetInstance()
{
// 我们只需要对第一次的new操作进行加锁,所以嵌套一层判断
if (nullptr == _tp)
{
pthread_mutex_lock(&_lock);
if (nullptr == _tp)
{
std::cout << "log: singleton create done first!" << std::endl;
_tp = new ThreadPool<T>();
}
pthread_mutex_unlock(&_lock);
}
return _tp;
}
private:
ThreadPool(int num = defaultnum)
: _threads(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
ThreadPool(const ThreadPool<T> &) = delete;
const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
~ThreadPool()
{
// 销毁锁、条件变量
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
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;
设计任务类
现在我们要做的就是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
此外,任务类当中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的,我们将Service封装在Run函数内
cpp
#include <string>
#include <cstring>
#include <iostream>
#include "Log.hpp"
extern Log _log;
class Task
{
public:
Task()
{
}
Task(int sockfd, const std::string &clientip, const uint16_t &clientport)
: _sockfd(sockfd), _clientip(clientip), _clientport(clientport)
{
}
// run起来, 但是只执行一次服务
void run()
{
// 读取缓冲区
char buffer[4096];
// 读取数据
ssize_t n = read(_sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "cilent say# " << buffer << std::endl;
// 回写数据
std::string echo_string = "TcpServer echo# ";
echo_string += buffer;
write(_sockfd, echo_string.c_str(), echo_string.size());
}
else if (n == 0)
_log(Info, "%s:%d quit, server close sockfd: %d", _clientip.c_str(), _clientport, _sockfd);
else
_log(Warning, "read error, errno: %d, errstring: %s", errno, strerror(errno));
// 线程执行完就close掉sockfd
close(_sockfd);
}
void operator()()
{
run();
}
private:
int _sockfd;
std::string _clientip;
uint16_t _clientport;
};
注意: 当任务队列当中有任务时,线程池当中的线程会先定义出一个Task对象,从任务队列当中获取任务,因此Task类除了提供带参的构造函数以外,还需要提供一个无参的构造函数,方便我们可以定义无参对象。
实际我们可以让服务器处理不同的任务,当前服务器只是在进行字符串的回显处理,而实际要怎么处理这个任务完全是由任务类当中的handler成员来决定的。
如果想要让服务器处理其他任务,只需要修改Handler类当中对()的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这就叫做把通信功能和业务逻辑在软件上做解耦。
TcpServer.hpp
cpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <netinet/in.h> //以上四个头文件是写套接字必需的
#include <pthread.h>
#include <signal.h>
#include "Log.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"
const int defaultfd = -1; // 默认listenfd
const std::string defaultip = "0.0.0.0"; // 默人服务器不绑定IP地址
const int backlog = 10; // 一般不要设置太大
// 定义出日志对象
Log _log;
// 错误类型
enum
{
Usage_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR
};
// 因为ThreadData用到了TcpServer的指针,所以先声明一下
class TcpServer;
// // 3.0版本需要用到的线程结构体,作为参数传递给Routine
// class ThreadData
// {
// public:
// ThreadData(int fd, const std::string &ip, const uint16_t &port, TcpServer *t)
// : sockfd(fd), clientip(ip), clientport(port), tsvr(t)
// {
// }
// public:
// int sockfd;
// std::string clientip;
// uint16_t clientport;
// TcpServer *tsvr; // TcpServer的this指针,因为Routine函数内需要使用成员函数Service
// };
class TcpServer
{
public:
// 构造函数内尽量不妨有风险的事情
TcpServer(const uint16_t &port, const std::string &ip = defaultip)
: _listensock(defaultfd), _ip(ip), _port(port)
{
}
/* 尽量将简单的初始化放在构造函数内,将有风险的东西放在独立函数内,例如创建套接字是可能失败的,在代码出异常时,不会影响对象的创建,只用在独立的函数内处理*/
void InitServer()
{
// 创建套接字
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0)
{
_log(Fatal, "socket create fail, errno: %d, errstring: %s", errno, strerror(errno));
exit(SOCKET_ERR);
}
_log(Info, "socket create success, _listensock: %d", _listensock);
// sockaddr_in 结构体初始化
struct sockaddr_in local;
bzero(&local, 0);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
// local.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_aton(_ip.c_str(), &(local.sin_addr)); // 用一下 inet_aton 接口
// bind
socklen_t len = sizeof(local);
if (bind(_listensock, (const struct sockaddr *)&local, len) < 0)
{
_log(Fatal, "bind error, errno: %d, errstring: %s", errno, strerror(errno));
exit(BIND_ERR);
}
_log(Info, "bind socket success");
// Tcp是面向连接的,服务器一般比较被动, 等待请求,建立连接
if (listen(_listensock, backlog) < 0)
{
_log(Fatal, "listen error, errno: %d, errstring: %s", errno, strerror(errno));
exit(LISTEN_ERR);
}
_log(Info, "listen socket success");
}
// 3.0 多线程版本使用
// static void* Routine(void* args)
// {
// // 线程分离,主线程不用等待新线程
// pthread_detach(pthread_self());
// ThreadData* td = static_cast<ThreadData*>(args);
// td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
// // 新线程执行完后要自己关闭sockfd
// close(td->sockfd);
// delete td;
// return nullptr;
// }
void Start()
{
// 线程池启动
ThreadPool<Task>::GetInstance()->start();
_log(Info, "TcpServer is running...");
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
// accept等待连接
int sockfd = accept(_listensock, (struct sockaddr *)&client, &len);
if (sockfd < 0)
{
_log(Fatal, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
continue;
}
// 获取client的IP、prot信息
uint16_t clientport = ntohs(client.sin_port);
char clientip[16];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));
_log(Info, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);
// 2.0 孙子进程
// pid_t id = fork();
// if (id == 0)
// {
// // 子进程把不需要的fd关掉,避免误写
// close(_listensock);
// if (fork() > 0) exit(0);
// Service(sockfd, clientip, clientport);
// close(sockfd);
// exit(0);
// }
// // 交给子进程之后再关闭sockfd文件,如果不关闭,那么会导致fd表空间越用越少
// close(sockfd);
// waitpid(id, nullptr, 0);
// 2.1 信号捕捉
// signal(SIGCHLD, SIG_IGN);
// pid_t id = fork();
// if (id == 0)
// {
// // 子进程把不需要的fd关掉,避免误写
// close(_listensock);
// Service(sockfd, clientip, clientport);
// close(sockfd);
// exit(0);
// }
// // 交给子进程之后再关闭sockfd文件,如果不关闭,那么会导致fd表空间越用越少
// close(sockfd);
// 3.0 多线程
// ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
// pthread_t tid;
// pthread_create(&tid, nullptr, Routine, td);
// 4.0 线程池
Task t(sockfd, clientip, clientport); // 定义任务对象
ThreadPool<Task>::GetInstance()->push(t); // 往线程池的任务队列push任务
}
}
// 1.0 - 3.0 需要Service成员函数,
// 4.0线程池不需要, 因为直接将Service封装在了Task的run函数内
// void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
// {
// // 读取缓冲区
// char buffer[4096];
// while (true)
// {
// // 读取数据
// ssize_t n = read(sockfd, buffer, sizeof(buffer));
// if (n > 0)
// {
// buffer[n] = 0;
// std::cout << "cilent say# " << buffer << std::endl;
// // 回写数据
// std::string echo_string = "TcpServer echo# ";
// echo_string += buffer;
// write(sockfd, echo_string.c_str(), echo_string.size());
// }
// else if (n == 0)
// {
// _log(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
// break;
// }
// else
// {
// _log(Warning, "read error, errno: %d, errstring: %s", errno, strerror(errno));
// break;
// }
// }
// }
~TcpServer()
{
if (_listensock >= 0)
{
close(_listensock);
}
}
private:
int _listensock; // 监听fd
uint16_t _port; // 端口号
std::string _ip; // ip地址
};
TcpClient.hpp
cpp
#include <iostream>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;
// 客户端提示命令行用法函数
void Usage(const std::string &proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
// 用法说明
Usage(argv[0]);
exit(1);
}
// 命令行参数获取服务器IP、port
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
// 初始化sockaddr_in类型的server
struct sockaddr_in server;
bzero(&server, 0);
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
// 连接服务器
int n = connect(sockfd, (const struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
cerr << "connect error" << endl;
return 2;
}
// 获取用户键盘输入
std::string message;
cout << "Please Enter# ";
getline(cin, message);
//向服务器写入信息
write(sockfd, message.c_str(), message.size());
// 缓冲区inbuffer
char inbuffer[4096];
// 从sockfd读取服务器处理后返回的数据
n = read(sockfd, inbuffer, sizeof(inbuffer));
if (n > 0)
{
inbuffer[n] = 0;
// 输出处理后的数据
cout << inbuffer << endl;
}
return 0;
}
因为我们为了不让线程太多,而采用为每一个客户只服务一次的原则,事实上大部分服务器也是这么干的,不然大量用户访问服务器,那么服务器要创建相应数量的线程,这也是很恐怖的事情,所以客户端的while发送信息要去掉while,改为单次
代码测试
运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程。
此时当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。
当第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的。
与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出。
1. 添加词典功能
在Task类内部,我们只是简单的将获取的信息发送回客户端,所以我们来添加一个小功能,词典翻译,英文译中文
添加词典txt文件,建立映射
简单编写一个类,用来词典翻译的处理
cpp
#include <iostream>
#include <fstream>
#include <string>
#include <unordered_map>
#include "Log.hpp"
extern Log _log;
const std::string dictname = "./dict.txt";
const std::string sep = ":";
static bool Split(std::string &s, std::string *part1, std::string *part2)
{
int pos = s.find(sep);
if (pos == std::string::npos)
return false;
*part1 = s.substr(0, pos);
*part2 = s.substr(pos + 1);
return true;
}
class Init
{
public:
Init()
{
std::ifstream in(dictname);
if (!in.is_open())
{
_log(Fatal, "ifstream open %s error", dictname.c_str());
exit(1);
}
std::string line;
while (std::getline(in, line))
{
std::string part1, part2;
Split(line, &part1, &part2);
dict.insert({part1, part2});
}
in.close();
}
std::string translation(const std::string &key)
{
auto iter = dict.find(key);
if (iter == dict.end())
return "Unknow";
else
return iter->second;
}
private:
std::unordered_map<std::string, std::string> dict;
};
在Task的Run函数内调用translation,获取返回值,将返回值返回给客户端
功能测试
2. 修补
建议在服务端将SIGPIPE信号进行忽略
如果服务器线程池中一个线程执行Task任务,正在调用run函数,刚刚read完数据还没来得及write回客户端,此时客户端关闭连接了,那么write向sockfd文件写入数据会失败。
这类似于管道的读端关闭,写端进程会被OS直接杀死,所以为了异常处理,我们在write之前将SIGPIPE信号进行捕捉,直接忽略该信号,避免客户端因各种意外断掉连接而导致服务器进程被杀死的情况,并对write进行日志记录
cpp
signal(SIGPIPE, SIG_IGN);
我们前面提到过,如果只是采用多线程为每一个客户进行长服务(while死循环,直到用户退出才结束服务),那么一亿个用户同时请求服务就要同时存在一亿的线程,显然我们的服务器是没有这么高的性能的,所以我们采用服务器短服务,只为每一个用户服务一次,然后就断开,但是这样对用户不友好,还要用户每次手动重连,所以在客户端,我们将connect放入while死循环内,每一次服务断开之后,再次请求连接,这样就保证了服务器线程池内的有限的线程可以为多个用户进行服务。
特别的sockfd的创建也需要放在循环中,因为服务器断开连接后,上一次客户端的sockfd就失效了,因为客户端的端口号是随机分配的,而且服务器会释放掉对应的网络文件,而服务端与用户端看到的是同一份网络文件,所以连接已经中断了,用户再使用与上一次相同的sockfd会连接不上服务器,所以需要重新创建套接字,建立新的连接。
- 客户断线重连机制,如果客户端connect失败,则会在限定次数内重连服务器,如果次数耗尽还连接不上,则直接报错退出
- 在每一次服务之后,需要关闭对应的sockfd,避免 fd 耗尽
cpp
#include <iostream>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
using namespace std;
// 客户端提示命令行用法函数
void Usage(const std::string &proc)
{
std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
// 用法说明
Usage(argv[0]);
exit(1);
}
// 命令行参数获取服务器IP、port
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 初始化sockaddr_in类型的server
struct sockaddr_in server;
bzero(&server, 0);
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
while (true)
{
int cnt = 5;
bool isreconnect = false;
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
do
{
// 连接服务器
int n = connect(sockfd, (const struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
cnt--;
isreconnect = true;
cerr << "connect error" << endl;
sleep(2);
}
else
break;
} while (isreconnect && cnt);
if (cnt == 0 && isreconnect == true)
{
cerr << "user offline..." << endl;
break;
}
// 获取用户键盘输入
string message;
cout << "Please Enter# ";
getline(cin, message);
// 向服务器写入信息
int n = write(sockfd, message.c_str(), message.size());
if (n < 0)
{
cerr << "write error..." << endl;
continue;
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer)); // 从sockfd读取服务器处理后返回的数据
if (n > 0)
{
inbuffer[n] = 0;
cout << inbuffer << endl; // 输出处理后的数据
}
close(sockfd);
}
return 0;
}
如果这个服务是需要长连接的,那么可以在大的循环内再添加一个循环
cpp
int main(int argc, char *argv[])
{
if (argc != 3)
{
// 用法说明
Usage(argv[0]);
exit(1);
}
// 命令行参数获取服务器IP、port
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 初始化sockaddr_in类型的server
struct sockaddr_in server;
bzero(&server, 0);
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
// server.sin_addr.s_addr = inet_addr(serverip.c_str());
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
while (true)
{
int cnt = 5;
bool isreconnect = false;
// 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
cerr << "socket error" << endl;
return 1;
}
do
{
// 连接服务器
int n = connect(sockfd, (const struct sockaddr *)&server, sizeof(server));
if (n < 0)
{
cnt--;
isreconnect = true;
cerr << "connect error" << endl;
sleep(2);
}
else
break;
} while (isreconnect && cnt);
if (cnt == 0 && isreconnect == true)
{
cerr << "user offline..." << endl;
break;
}
while (true)
{
// 获取用户键盘输入
string message;
cout << "Please Enter# ";
getline(cin, message);
// 向服务器写入信息
int n = write(sockfd, message.c_str(), message.size());
if (n < 0)
{
cerr << "write error..." << endl;
break;
}
char inbuffer[4096];
n = read(sockfd, inbuffer, sizeof(inbuffer)); // 从sockfd读取服务器处理后返回的数据
if (n > 0)
{
inbuffer[n] = 0;
cout << inbuffer << endl; // 输出处理后的数据
}
else
break;
}
close(sockfd);
}
return 0;
}
为了防止服务挂掉之后,又重启不起来该端口,我们要使用setsockopt设置套接字属性,在服务端创建socket之后进行一下操作,具体原理我们在tcp一节讲解
cpp
// 防止偶发性的服务器不能立即重启
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
至此我们的服务还差一口气,因为如果服务器进程没启动,那么服务器就不会进行服务
五、守护进程
1. 会话
1. 会话的定义
- 交互式会话: 每个用户登录系统后,都会创建一个会话,这个会话包含了用户的所有进程。
- 非交互式会话: 一些系统服务或脚本运行时,也可能创建会话,但这些会话通常没有用户交互。
当我们尝试在Linux系统登录一个用户时,OS会为我们创建一个会话session,每一个用户都创建一个session。
我们在基础IO一节讲解了前台进程与后台进程,一个会话只能有一个前台进程和多个后台进程,并且**只有前台进程能获取键盘输入,但是都可以向显示器显示,所以前台与后台进程的区别就在于谁能获取键盘输入!**所以后台进程不能从标准输入获取数据
这是一个合理的规则,因为OS内有很多进程,如果不规定谁来获取键盘输入,那么如果我们输入hello,这可能导致多个进程争抢hello各个字符
2. 会话的组成
- 进程组: 会话中包含一个或多个进程组。进程组是一个或多个相关进程的集合,它们共享同一个父进程。
- 终端: 交互式会话通常与一个终端相关联,用户通过终端与系统进行交互。
举例:
- 用户登录系统后,打开一个终端,这个终端就是一个交互式会话。
- 用户在终端中运行一个应用程序,这个应用程序就是一个进程,属于这个会话。
- 用户在终端中运行多个应用程序,这些应用程序可能属于同一个进程组,也可能属于不同的进程组,但它们都属于同一个会话。
2. 会话管理
ps
命令: 用于查看当前会话中的所有进程。jobs
命令: 用于查看当前会话中的所有后台进程。fg
命令: 用于将后台进程切换到前台。bg
命令: 用于将前台进程切换到后台。kill
命令: 用于向进程组中的所有进程发送信号。
jobs
对于后台进程,我们可以使用jobs指令查看后台进程的信息,【】内显示的是任务号
fg
fg指令可以将后台进程调换至前台运行,但是需要注意fg后面跟的是任务序号(在jobs内被划分的序号)
此时向键盘输入ctrl + c,该进程就会收到信号进而终止
如果我们又想将该进程切回后台进程,我们输入ctrl z,此时该进程会被stop,然后bash进程默认被调至前台
补充:一个会话中必须要有一个前台进程,不能空缺,因为如果没有一个进程作为前台进程,所有进程包括bash都在后台,那么用户在这个会话内,把键盘敲烂OS内都不会有任何进程获取数据,没有任何反应,所以OS对于前台进程有一个规定,如果前台进程被中止、阻塞了,那么OS会立即调取后台进程bash作为前台进程
bg
对于被ctrl z的后台进程,我们可以使用bg指令,对该进程唤醒
我们再来看看进程与任务的区别
- **PPID:**父进程id
- **PID:**进程id
- **PGID:**进程组id,一个任务可以由一个进程来完成,也可以由多个进程协同完成,所以对于单进程来说,他一个人成一组,对于协同的进程来说,它们属于同一个进程组,该组的PGID以第一个进程的PID命名
- **SID:**会话ID,因为一个用户有一个会话,多用户的多会话需要被统一管理,所以OS先描述再组织会话,构成SID
- TTY:终端号
- 每一次用户登录,OS会为该用户创建一个会话和一个bash前台任务,并且该会话的SID以bash的pid命名。
所以在会话中前台进程与后台进程应该被纠正为前台任务与后台任务
3. 守护进程化
**守护进程:**在Linux中守护进程(Daemon)是一种后台服务进程,它独立于其他程序运行,通常是为了执行特定的任务或等待处理某些事件。
守护进程通常是在系统启动时启动,持续运行到系统关闭,它们不与用户直接交互,而是提供服务给其他进程或程序
在Linux中,退出当前会话,再次登陆时上一次会话剩余的后台进程还在,但是它的ppid变为1,即被OS领养,而在windows中,一次注销、重新登陆,上一次会话的进程就已经被释放,所以我们能不能创建一个不受用户登录和退出影响的进程呢?
让一个进程自成一个进程组、自成一个会话,所以用户形成的会话的创建和销毁就不会影响该进程的状态,这样的进程被称为守护进程,守护进程默认在在执行文件后跟d,例如mysqld,httpd等等
如何操作?
函数功能:
- 进程调用该函数后,会创建一个session,它的SID以该进程的进程组ID命名,并且一个进程组的组长不能创建新的session,组员可以
也就是说调用该函数后,该进程就成为了
返回值:
- 成功则返回新创建的session的SID
- 失败则返回-1,并设置错误码
但是我们单起的进程一般都是独立的自成一组,自己就是组长,那么我们该如何让该进程调用setsid成功呢?
一般而言 ,一个进程组的第一个进程就是组长,那么我们不让我们的进程称为第一个进程就算解决问题了,所以我们可以让子进程去setsid
cpp
if (fork() > 0) exit(0);
setsid();
新建一个dmaeon.hpp文件,编写守护进程的代码
cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string filename = "/dev/null";
// 守护进程
void Daemon(const std::string &cwd = "")
{
// 1. 忽略其他异常信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
// 2. 将自己变成独立的会话
if (fork() > 0)
exit(0);
setsid();
// 3. 更改当前调用进程的工作目录,因为守护进程比较独立,放到根目录下比较合适
if (!cwd.empty())
chdir(cwd.c_str());
// 4. 将标准输入、标注输出、标准错误重定向到/dev/null
int fd = open(filename.c_str(), O_RDWR);
if (fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
相关细节:
- 先捕捉一些容易引起守护进程异常退出的信号
- 创建子进程,此时就形成了一个进程组,组长是父进程,此时让父进程退出,子进程就可以设置新会话了
- 如果函数传递了路径参数,那么需要修改子进程即守护进程的工作路径,因为守护进程是一个新起的会话,是独立的,由OS管理时会依照绝对路径进行访问,所以建议放在根目录下
- 因为守护进程是独立的会话,所以不能关联标准输入、标准输出、标准错误,但是如果直接close关闭这三个文件,那么我们的服务端一旦启动守护进程化代码,服务端后续的各种输出都会报错,因为0,1,2都不存在,所以解决这个问题应该是疏通而不是阻止,我们将0,1,2都重定向到垃圾桶/dev/null,向这个文件的任何输入都会直接删除。
服务端后续的日志报错怎么处理?
当一款产品上线时,日志就不能再输出到屏幕了,这样的维护很难,因为一旦出问题,总不能让一个人一直盯着不断刷屏的屏幕找日志信息,所以都是将日志单独放在一个文件中,我们实现的日志类实现时就考虑到了日志向文件输入情况,直接修改日志输出方向即可,多文件日志打印时要注意提前创建一个log目录,因为我们当时编写的日志类是向该目录下分批输出的
注意:如果在日志分批存放处理前进行了守护进程化并将cwd修改到根目录下,那么日志分类时寻找的 ./log/ 路径就失效了,日志不会打印到文件内,所以该日志路径应改为绝对路径,或者守护进程不修改cwd
TcpServer.hpp
将daemon添加在服务端的start函数中
执行服务端
可以看到我们的程序启动之后出现类似退出的现象,其实是我们的服务已经变为守护进程了,我们先用netstat指令查看
再来看该守护进程pid对应的情况
这就验证了守护进程是一个独立的进程组、独立的会话的理论,它的终端是不明确的 。我们再来观察该守护进程的cwd,当前工作路径,因为我们没有传递参数,所以该进程的cwd并没有改变
再来看守护进程打开的文件信息
0,1,2标准输入输出错误文件都重定向到了 /dev/null 垃圾桶内,闪红的文件描述符对应的是服务端创建的套接字网络文件
守护进程规范一点,将tcpserver可执行程序改名为tcpserverd
4. daemon库函数
daemon 系统也为我们提供了守护进程的接口,与我们实现的并没有区别,用哪个都行
参数说明:
- **nochdir:**0表示默认chdir, 修改cwd为根目录,否则表示默认为当前目录
- **noclose:**0表示默认将标准输入、输出、错误重定向到 /dev/null,否则不重定向