文章目录
-
- 简单的TCP回响程序
- 字典功能设置
- 服务端写操作的处理
- 客户端的修改
- 前台进程和后台进程
- 字典服务的守护进程化
- TCP的三次握手和四次挥手
- [TCP 的全双工](#TCP 的全双工)
- 参考代码
简单的TCP回响程序
在博客『 Linux 』TCP套接字网络通信程序中实现了一个TCP的回响程序,依次使用了单进程,多进程,多线程以及线程池的方式进行实现;
-
单进程
对于单进程版本而言,本质上是一个单执行流的程序,而这个服务端因为需要调用
Server
函数会使得一个服务端只为一个客户端提供服务,其他的客户端的服务必须等待前一个客户端断线,也就是服务端与客户端之间取消联系才能继续为其他的客户端提供服务,但依旧是只为一个客户端提供服务; -
多进程
多进程的版本通过
fork
创建子进程的方式使得一个服务端可以同时为多个客户端提供服务,但在实现过程中存在了一个问题,即子进程在退出时父进程必须进行等待,否则将会出现僵尸线程从而造成内存泄漏;而对于这种情况提供了两种办法:
-
双重
fork()
通过两次
fork()
,第一次的fork()
创建子进程,第二次的fork()
在子进程中再次创建子进程,使得服务端通过双重fork()
创建出孙子进程,当孙子进程创建完毕后将子进程退出,此时由于子进程必须存在一个父进程来管理,所以这里的孙子进程将会被"托孤"给操作系统(通常情况下PID
为1
),当孙子进程退出时操作系统将无需观察或者等待子进程的状态从而提高服务端整体的效率; -
忽略
SIGCHLD
子进程变成僵尸进程的本质原因是,当子进程退出时会向父进程发送
SIGCHLD
信号以告诉父进程自己已经执行完毕,当父进程接受到子进程的这个信号时将会把这个已经结束的子进程放置在自己的"处理队列"中,相当于形成一个约定,即会对子进程进行回收,但若父进程没有通过wait()
或者waitpid()
来回收子进程时子进程将一直成为僵尸进程,维持僵尸状态在父进程的"处理队列"中,直至父进程调用wait()
或waitpid()
进行回收;但如果子进程发送给父进程
SIGCHLD
信号时信号被父进程忽略,则这个子进程不会被父进程放到所谓的"处理队列"中,而是直接退出;
多进程版本的服务端解决了服务端只能为一个客户端提供服务的状态;
但多进程版本服务端的缺点很明显是,每当一个客户端向服务端发起连接并与服务端建立起连接时都表示当前进程又再次创建了一个进程,而进程本质上无论是创建线程的开销还是进程的维护代价都是很大的,同时当计算机中进程过多时将会加大操作系统的负载,同时当当前计算机中锁存在的进程要大于限制时操作系统将会根据优先级把一些没必要的进程杀死,这样反而"得不偿失";
-
-
多线程版本
创建一个进程的开销和代价是很大的,而创建一个线程的开销则小得多,创建进程需要为这个进程维护新的
PCB
结构体,页表,虚拟地址空间,文件描述符等等,而创建一个线程只需要为这个线程维护一个新的TCB
结构体(在Linux中)以及开辟新的栈帧即可,因为在同一个进程中的多个线程将共享文件描述符表,进程地址空间等资源,因为线程是比进程更细的执行流;但多线程版本同样存在一些短板,多进程版本的服务端由于服务端
fork()
创建的进程,进程与进程之间是相互独立的,这包括文件描述符表,而在进行TCP网络通信时服务端必然会打开一个网络文件,并且打开这个网络文件时将会返回一个新的TCP套接字文件描述符,而这个套接字描述符是占用文件描述符表的;意思是服务端每新创建一个相同的进程实际上在关闭了多余的文件描述符后每个子进程因处理客户端发来的请求的文件描述符是相同的,而多线程中由于同一个进程间的不同线程共享同一个文件描述符表,这表示在多线程版本中不存在多余的文件描述符,但同样的文件描述符是一个有限资源,当服务端中的文件描述符资源被使用完毕后,再次创建线程来进行网络通信时这次的网络通信将会失败;
同样的虽然创建线程更多代价是极小的,但苍蝇腿也是肉,每当来一个客户端的连接都需要一定的开销,就算平常情况可能不会有非常大的开销但必然存在峰值情况,即服务端可能会在一个时间点存在大量的客户端来向其发起连接;
同时服务端的服务分为长服务和短服务,如果只是单独的回响程序,在设计中应该考虑把这个服务设计为短服务,即每当一个客户端向服务端发起链接时服务端为这个客户端提供服务,当本次服务结束后应断开连接从而避免峰值,当客户端再次需要服务时需要重新向服务端发起连接;
-
线程池版本
线程池版本完美了解决了上述几个版本的问题;
服务为短服务,每当服务端接收到一个客户端的连接时将会与客户端建立连接并为这个客户端提供服务,当本次服务结束后则断开连接;
同时由于是短服务,每当服务端为一个客户端提供完服务时会断开连接,相应的会把TCP套接字描述符关闭,这也表示当多个客户端向服务端发起请求时,文件描述符在增多的同时必然也在随着本次服务结束被关闭而减少,相当程度的减少了峰值情况文件描述符被用尽的情况;
由于线程池会率先准备好一批线程,这也避免了服务端在每次与客户端建立起连接时才创建线程进行后续操作从而降低服务端整体的效率;
字典功能设置
既然是一个英译汉的程序,那么必须存在对应的字典;
在当前目录下创建了一个名为dict.txt
文件,文件中存放了一些英译汉的简单词汇,英文和汉字之间以:
作为分隔符;
bash
$ cat dict.txt
cat:猫
dog:狗
house:房子
car:汽车
tree:树
book:书
phone:电话
chair:椅子
table:桌子
key:钥匙
lamp:灯
window:窗口
shoe:鞋
hat:帽子
shirt:衬衫
door:门
pen:笔
clock:钟
river:河流
同时作为一个字典服务,那么必须对字典进行初始化,初始化字典即打开对应的字典文件,将文件中的内容一行行进行读取,并进行分割,将分割的左右部分以key-value
的结构放置在容器当中;
-
初始化
cpp/* Init.hpp */ const std::string dictname = "./dict.txt"; class Init { public: Init() { std::ifstream in(dictname.c_str()); if (!in.is_open()) { lg(FATAL, "ifstream open fail"); exit(2); } std::string line; while (std::getline(in, line)) { std::string part1, part2; Split(line, &part1, &part2); dict.insert(part1, part2); } in.close(); } private: std::unordered_map<std::string, std::string> dict; };
在这段代码中首先使用
const string dictname
定义了C++文件流中需要打开的字典文件;使用
ifstream
并传入需要打开的字典文件路径作为参数构造一个文件流对象,当这个文件流对象被实例化后默认为打开,当然也可能不为打开状态,所以调用is_open()
成员函数判断当前文件流是否被打开,如果没有被打开则直接退出服务端;当打开了这个文件后需要对字典文件中的内容进行按行读取,这里调用了
getline
函数进行了按行读取,这里使用了while
循环,当getline
函数读取成功时将会返回对应读取到的字符串对象,读取失败则返回空;并且调用了自定义函数
Split
对按行读取的字符串进行分割,当分割完毕后将分割好的左右部分分别以key-value
的方式放置在哈希表unordered_map
容器中,当字典文件中的内容全部被读取完毕时将会调用; -
字符串分割
字符串分割主要是调用
string::find()
查找当前行是否存在分隔符:
,若是存在则调用string::substr()
将字符串进行分割为左右部分;cpp/* Init.hpp */ const std::string sep = ":"; Log lg; static bool Split(const std::string &line, std::string *part1, std::string *part2) { auto pos = line.find(sep); if (pos == std::string::npos) { return false; } *part1 = line.substr(0, pos); *part2 = line.substr(pos + 1); return true; }
其中
part1
和part2
为输出型参数,分割后的字串将会以对象的指针的方式传递给对应的part1
和part2
; -
英汉互译
准确的来说既然是一个词典,那么文件与文件之间可以降低耦合度,那么字典的处理则可以依旧交给
Init.hpp
文件中进行处理;上述的英汉词汇在分割之后已经以
key-value
的方式存放在了unordered_map
哈希表中了,那么只需要在哈希表中寻找对应的key
值并返回value
即可以完成一个简单的英汉互译功能;cpp/* Init.hpp */ class Init { public: const std::string& translation (const std::string &key){ auto iter = dict.find(key); if(iter==dict.end()){ return "Unknow"; } return iter->second; } private: std::unordered_map<std::string, std::string> dict; };
-
任务类的设置
英汉互译的功能已经在
Init
类中实现了,这个线程池版本的TCP程序主要依靠Task
任务类进行任务的实现,所以只要在任务类中调用字典功能服务端就可以使用字典功能;cpp/* Task.hpp */ class Task { public: // 执行任务的主要函数 void run() { char buff[4096]; int n = read(sockfd_, buff, sizeof(buff)); if (n > 0) { buff[n] = 0; std::cout << "Client key# " << buff << std::endl; std::string ret = init.translation(buff); write(sockfd_, ret.c_str(), ret.size()); // 发回客户端 } else if (n == 0) { lg(INFO, "Client %s:%d quit... ", clientip_.c_str(), clientport_); } else { lg(WARNING, "read error , error message: %s", strerror(errno)); } close(sockfd_); } private: int sockfd_; std::string clientip_; uint16_t clientport_; };
这个任务类中
run
函数构造了一个Init
类对象,并且接收了来自用户从键盘中输入的字符串,调用Init::translation()
函数并传入用户输入的英文单词从哈希表中匹配,最后将结果发回给客户端; -
测试
在这个测试中使用服务端开启服务,打开客户端并向服务端发送对应的英文单词作为请求,服务端通过
Task
任务类并使用Init
类中的transplation
方法,从对应的哈希表中根据用户输入的key
值返回其value
;这里的客户端并未修改,客户端应用的是长服务,但服务端此时提供的是短服务,当客户端第二次尝试使用该服务时服务端已和客户端断开连接,再次需要服务时客户端需要重新运行并发起连接;
服务端写操作的处理
cpp
class Task
{
public:
void run()
{
char buff[4096];
int n = read(sockfd_, buff, sizeof(buff));
if (n > 0)
{
buff[n] = 0;
std::cout << "Client key# " << buff << std::endl;
std::string ret = init.translation(buff);
write(sockfd_, ret.c_str(), ret.size()); // 发回客户端
}
else if (n == 0)
{
lg(INFO, "Client %s:%d quit... ", clientip_.c_str(), clientport_);
}
else
{
lg(WARNING, "read error , error message: %s", strerror(errno));
}
close(sockfd_);
}
};
这是当前服务端的读写操作,其中读操作进行了完善,当读操作失败时对应的会根据读取失败的情况来进行相应处理,当读到的字节数为0
时表示客户端已经退出,如果read
函数的返回值>0
则表示在调用read
时调用失败,将会打印出对应的错误码;
但这里的write
也会失败,失败的原因有两种:
-
错误的套接字描述符
当程序向一个错误的(不存在)的套接字描述符进行写入操作时写入操作将失败;
cppvoid run() { // ... write(100, ret.c_str(), ret.size()); // 发回客户端 // ... close(100); }
假设将服务端相应回客户端的套接字描述符改为
100
(这里的100
必然是不存在的文件描述符),对应的写操作将会失败,失败原因是在调用write
接口时使用了错误(不存在)的文件描述符;这是一个英汉互译词典的程序,且服务端是一个短服务,这里的客户端还没有被更改,但即使未修改客户端代码客户端也会接收一次来自服务端的相应,如上文中服务端设计结束后的测试一样;
但这里的客户端并没有回显来自服务端的响应,因为服务端的响应从
100
文件描述符中进行写入,而这个文件描述符并不是一个有效的文件描述符;因此
write
对网络套接字描述符的写入与对文件描述符的写入实际上是一致的,既有写入成功也有写入失败;bashRETURN VALUE On success, the number of bytes written is returned (zero indicates nothing was written). On error, -1 is returned, and errno is set appropriately.
这是
write
系统调用接口的返回值,返回值指出当这个函数调用成功时将返回一个非负整数,表示写入了多少字节的数据,返回0
则表示什么都没写;当写入失败时返回
-1
并设置全局变量errno
错误码标识其错误信息;所以只要对返回值进行判断,如果
write
调用失败就做作出相应处理即可;cppn = write(100, ret.c_str(), ret.size()); // 发回客户端 if(n<0){ lg(WARNING,"write error, error message: %s",strerror(errno)); }
这里的
100
只是一个测试,测试这条日志信息是否可以被打印;测试结果为,当服务端向一个不存在或者已经被关闭的套接字描述符进行写入时将会写入失败,这里的失败原因为
Bad file descriptor
表示不存在的文件描述符; -
写入中套接字描述符被关闭
TCP套接字在进行网络通信时本质上与管道相同,唯一不同的时TCP是全双工的,而管道是单向数据流的;
但如果TCP正在进行网络通信时,如服务端正在向客户端通过TCP网络套接字进行写入时客户端退出了,那么服务端将会收到一个
SIGPIPE
信号,这个信号会直接终止进程;这种情况不是一种必现的情况而是一种偶发的情况,为了避免这种偶发的情况发生,最优雅的方式就是在服务端的
Start
位置将SIGPIPE
信号进行忽略;cppclass TcpServer { public: void Start() { signal(SIGPIPE,SIG_IGN); // 将 SIGPIPE 信号设置为忽略 ThreadPool<Task>::getInstance()->Start(); lg(INFO, "TcpServer start sucess..."); while (true) { // 获取连接 /* ... */ } private: int listen_sockfd_; // 服务端的监听套接字描述符 uint16_t port_; // 服务端的监听端口 protected: static const uint16_t defaultport; // 默认端口号 };
客户端的修改
原版的整体框架为服务端提供长服务,所以客户端只需要向服务端发起一次连接即可;
cpp
// 原版的客户端
/* tcpclient.hpp */
class TcpClient {
public:
TcpClient(const std::string& ip, int16_t port)
: sockfd_(-1), ip_(ip), port_(port) {}
void Init() {
// 创建TCP套接字
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0) {
printf("socket error , error message: %s\n", strerror(errno));
exit(2);
}
}
void Request() {
std::string request;
char buff[4096];
while (true) {
// 发起请求
std::cout << "Please Enter# ";
std::getline(std::cin, request);
write(sockfd_, request.c_str(), request.size());
// 接收响应
int n = read(sockfd_, buff, sizeof(buff));
if (n > 0) {
buff[n] = 0;
std::cout << buff << std::endl;
} else if (n == 0) {
std::cout << "Server exit...." << std::endl;
break;
} else {
printf("Client read error, error message: %s\n", strerror(errno));
break;
}
}
}
void Start() {
// 对服务端发起连接 请求 以及接收服务端的响应
// 发起连接
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_pton(AF_INET, ip_.c_str(), &(server.sin_addr));
server.sin_port = htons(port_);
socklen_t len = sizeof(server);
if (connect(sockfd_, (sockaddr*)&server, len)) {
printf("connect error , error message: %s\n", strerror(errno));
exit(3);
}
Request();
}
~TcpClient() {
// 关闭无用的TCP套接字描述符
if (sockfd_ != -1) {
close(sockfd_);
sockfd_ = -1;
}
}
private:
int sockfd_; // 套接字描述符
std::string ip_; // 用户传递的IP地址
int16_t port_; // 用户传入的端口号
};
/* client.cc */
void Usage() { printf("\n\tUsage : ./client ip port[port>1024]\n\n"); }
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
exit(-1);
}
// 获取用户输入的IP与端口号
int16_t port = std::stoi(argv[2]);
std::string ip(argv[1]);
// 实例化客户端对象
TcpClient tc(ip, port);
tc.Init();
tc.Start();
return 0;
}
但使用了线程池对服务端进行修改,并限制服务端只提供短服务时客户端就需要设计为每次向服务端发起一次请求时都需要重新向服务端发起连接,这是一个循环操作;
服务端每次为客户端提供一次短服务后会关闭与客户端的连接,客户端需要保证在自己不退出或者用户不允许客户端退出的情况下重新向服务端发起连接,当连接建立成功后重新让用户再次输入内容并将这个内容作为一个请求发给服务端,此时客户端需要重新创建一个套接字,因为当服务端关闭与客户端的连接后上一次的客户端的套接字描述符将变得无效;
在这段代码中创建套接字的功能被封装在Init()
成员函数中,所以只需要循环调用Init()
函数与Start()
函数就能完美的将客户端修改为与服务端匹配的客户端;
cpp
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
exit(-1);
}
// 获取用户输入的IP与端口号
int16_t port = std::stoi(argv[2]);
std::string ip(argv[1]);
// 实例化客户端对象
TcpClient tc(ip, port);
while (true) // 如果失败则重新为客户端对象进行一次初始化
// 初始化包括重新为客户端创建套接字等等
{
tc.Init();
tc.Start();
}
return 0;
}
在这段代码中循环调用了TcpClient
客户端类的初始化函数Init()
和执行函数Start()
;
同时在原版的客户端中客户端Request()
请求函数中与服务端建立连接后为了匹配服务端的多次让用户输入循环发起请求的动作也就显得没有必要,因为新的客户端会在服务端保持在线的状态循环重新创建套接字,重新发起连接最后重新发起请求;
cpp
void Request()
{
std::string request;
char buff[4096];
// 发起请求
std::cout << "Please Enter# ";
std::getline(std::cin, request);
int n = write(sockfd_, request.c_str(), request.size());
if (n < 0)
{
std::cerr << "warning write err..." << std::endl;
return;
}
// 接收响应
n = read(sockfd_, buff, sizeof(buff));
if (n > 0)
{
buff[n] = 0;
std::cout << buff << std::endl;
}
else if (n == 0)
{
std::cout << "Server exit...." << std::endl;
}
else
{
printf("Client read error, error message: %s\n", strerror(errno));
}
}
在这段函数中删除了客户端Request()
函数中冗余的while
循环;
同时上文提到了write()
会调用失败,所以同样的在这里对write()
进行了差错处理,但是客户端中并没有对SIGPIPE
进行错误处理,因为作为一个客户端需要让用户知道错误的位置在哪里而不是不让用户感知错误的位置以及具体问题;
-
测试
使用
8080
端口打开服务端,客户端使用环回地址连接服务端进行第一次连接,当用户输入数据后数据将会交给服务端进行处理并响应回客户端,客户端不会退出,服务端会关闭此次连接,可以看到每次的客户端的端口号都不同,表示每次用户需要服务时都会重新bind
绑定,并向服务端发起连接;
断线重连
当服务端断线或者网络不稳定的情况下作为一个客户端都会感知到,并且重新向服务端发起连接,这个操作是在客户端中进行的,因为服务端虽然会对一个完成服务的客户端关闭连接但是不会关闭整个服务端,所以当客户端感知到网络不稳定或者是断线的情况下客户端应该主动向服务端发起连接;
发起连接的操作是在客户端类中的Start()
函数中的connect()
函数,可以使用do{}while()
在不存在冗余操作的前提下能够使客户端重新向服务端发起连接;
cpp
class TcpClient
{
public:
void Start()
{
isrunning_ = true;
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
inet_pton(AF_INET, ip_.c_str(), &(server.sin_addr));
server.sin_port = htons(port_);
socklen_t len = sizeof(server);
int reconnectcnt = 5; // 用户断线重连的次数
do // do while 确保第一次能够正常连接
{
if (connect(sockfd_, (sockaddr *)&server, len))
{
printf("connect error, reconnecting(%d)...\n",reconnectcnt);
isreconnect_ = true;
reconnectcnt--;
sleep(1);
}
else
{
isreconnect_ = false;
}
} while (isreconnect_ && reconnectcnt);
if (reconnectcnt == 0)
{
printf("user unline...\n");
isrunning_ = false;
return;
}
Request();
}
bool GetRunningStat()
{
return isrunning_;
}
private:
bool isreconnect_; // 判断是否需要重连
bool isrunning_; // 判断客户端是否正在运行
};
这里进行了一个断线重连的处理,在成员变量中添加了一个bool
类型的isreconnect_
变量(默认为false
),在函数中定义了一个变量reconnectcnt
控制客户端重连的次数,使用do{}while()
控制连接,如果连接失败isreconnect_
将会被修改为true
代表连接失败需要重连,当reconnectcnt>0
且isreconnect_==true
两种情况下会进行重连,当连接次数减为0
时代表重连失败;
同时还定义了一个成员变量isrunning_
来判断当前的客户端是否还需要运行(默认为false
,调用Start()
函数时将被修改为true
),不需要运行的情况为重连失败对应的isrunning
会被修改为false
,这个成员变量主要是交给外层调用TcpClient
类的main()
函数的,因为main()
函数循环调用客户端类的Init()
和Start()
函数,在成员函数中无法终止外层main()
函数的循环,因此需要专门的一个成员变量作为条件变量,同时为了保证封装性,这个变量不能被暴露在publiuc
作用域中,所以需要再定义一个函数GetRunningStat()
来返回这个变量的状态,当main()
函数在循环调用Init()
函数和Start()
函数时需要判断这个客户端的状态来判断它是否有必要进入循环来进行下一次服务;
cpp
int main(int argc, char *argv[])
{
//...
// 实例化客户端对象
TcpClient tc(ip, port);
while (true) // 如果失败则重新为客户端对象进行一次初始化
{
tc.Init();
tc.Start();
if (!tc.GetRunningStat())
{
return 1;
}
}
return 0;
}
同时这里的reconnectcnt
参数不需要在循环中重新初始化,因为这个值是伴随一次服务的,若是本次掉线之后再次掉线,即连续两次掉线,那么第二次掉线将会在客户端下一次重新创建TCP套接字时重新连接;
-
测试
当服务端运行过后运行客户端,关闭服务端后使用客户端向客户端发起连接,客户端会尝试重连,但是这里由于TCP的端口重用限制,TCP套接字关闭一次后无法立即使用同一个端口再次绑定(不作过多解释);
绑定失败
可以在服务端中使用setsockopt()
函数设置允许立即重新绑定使用了静态端口的套接字;
bash
NAME
getsockopt, setsockopt - get and set options on sockets
SYNOPSIS
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname,
void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
RETURN VALUE
On success, zero is returned. On error, -1 is returned, and errno is set appropriately.
函数不作过多解释;
cpp
/* tcpserver.hpp */
class TcpServer
{
public:
// 构造函数初始化 TcpServer 类
TcpServer(uint16_t port = defaultport) : listen_sockfd_(-1), port_(port) {}
void Init()
{
// 创建套接字 绑定 监听
// 套接字创建
listen_sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sockfd_ < 0)
{
lg(FATAL, "create socket error , error message: %s", strerror(errno));
exit(SOCKET_ERR);
}
lg(INFO, "create socket sucess, sockfd:%d", listen_sockfd_);
int opt = 1;
setsockopt(listen_sockfd_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法进行立即重启
// 绑定
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;
socklen_t len = sizeof(local);
if (bind(listen_sockfd_, (sockaddr *)&local, len) < 0)
{
lg(FATAL, "bind error , error message: %s", strerror(errno));
exit(BIND_ERR);
}
// 设置监听
if (listen(listen_sockfd_, backlog) < 0)
{
lg(FATAL, "listen error , error message: %s", strerror(errno));
exit(LISTEN_ERR);
}
}
};
在服务端的Init()
函数中调用setsockopt()
函数设置放置偶发性的服务器无法进行立即重启;
测试
使用8800
端口打开服务端,使用环回地址连接服务端,当服务端被使用Ctrl + C
关闭时客户端向服务端发起请求时将会连接失败,客户端将会重连;
前台进程和后台进程
这个服务端的字典服务以及客户端的编写也接近完成,但是还有一点与真正的服务不同,真正的服务并不会因为shell
的关闭而退出,而该服务端程序将会因为bash
进程的退出而退出,换句话来说就是当前的服务端生命周期是随当前bash
进程的;
假设有一个程序,程序代码为:
cpp
int main()
{
while (true)
{
cout << "hello world" << endl;
sleep(1);
}
return 0;
}
这个程序为循环每隔一秒打印一次hello world
;
当直接运行这个程序时命令行将不可用,因为当直接运行程序时原本的前台进程bash
将会自动调度到后台,前台进程将变为该程序,而在当前环境中只有bash
程序负责解析用户所输入命令行中的命令,但由于bash
被调到度成为后台进程所以无法接受用户所输入的命令;
对应的这个程序可以被键盘命令所终止;
-
会话
当一个用户登录Linux系统时,系统会自动为这个用户创建一个会话,系统中可以同时存在多个会话;
当用户成功登录后,操作系统会为这个用户所在的会话自动运行一个
bash
进程,这个bash
进程是一个前台进程,用于解析并执行用户所传递的命令,为用户提供对应的命令行服务;
对应的一个程序可以使用&
将其放置在后台运行;
当一个程序被以后台进程的方式运行时将不会接收到用户从键盘中的数据,这里的打印依旧在显示器中本质原因是打印的方式为在显示器中进行打印,但不代表它为前台进程,在这里中尝试利用键盘Ctrl + C
为进程发送SIGINT
信号,但是进程未被终止,原因是键盘信号只能发送给前台进程,这里的a.out
程序是以后台进程的方式运行,因此接收不到键盘信号;
在一次会话当中可能存在多个后台进程和一个前台进程,前台进程只能有一个,默认的前台进程为bash
进程,为用户提供命令行服务,当一个新的进程以前台进程的方式运行,那么默认的bash
进程将自动切换到后台进程队列中;
-
前台进程与后台进程的区别
在上面的程序中无论以前台进程的方式运行还是以后台进程的方式运行最终结果都会打印到显示器上,说明显示器的打印并不是区别前台进程与后台进程的关键点;
真正意义上来说,谁当前持有键盘文件,谁就是前台进程;
后台进程可以从标准输出中斤进行写入操作,但是无法从标准输入中获取数据;
一般情况下当一个进程以后台方式运行时,若是这个进程会将对应的打印输出在显示器文件中可以根据需求将这个进程所输出的内容重定向至一个文件中以保证命令行的利用率;
-
基本操作
当一个程序被用户以
./processname
的方式运行时默认这个进程为前台进程,可以获取到键盘信号,也意味着可以使用键盘信号杀死这个进程;当一个程序被用户以
./processname &
的方式运行,那么这个进程默认为后台进程,后台进程无法接收到键盘信号,无法被键盘信号杀死,当一个进程以后台进程的方式运行时将会默认生成一个任务号和一个进程的PID
;bash[1] 31582
其中
[1]
表示是一号任务,后面跟着的为进程的pid
;一个会话中可以存在多个后台进程,可以使用
jobs
命令查看当前会话中存在多少个后台进程;可以使用
fg Tasknum
的方式,即fg
命令带上任务号的 方式将一个后台进程提到前台,前台进程可以使用键盘信号杀死;当一个后台进程被使用
fg
命令提到前台时若是需要再将这个进程从前台切换至后台时需要使用Ctrl + Z
将这个前台进程暂停,Ctrl + Z
的本质是向这个前台进程发送一个SIGSTOP
信号,当这个前台进程被暂停时将会被自动调度到后台进程队列中,并自动将bash
进程从后台切换至前台,原因是如果前台进程暂停了但不将bash
调度回前台进程,那么当前前台中将不存在有效的正在运行的进程,又因为前台进程持有标准输入,那么在他被暂停的前提下用户将无法使用命令行功能;当这个进程由于被暂停而自动被调度到了后台进程,但是这个进程仍处于暂停状态并未继续运行,所以需要再次调用
bg
命令,以bg
命令带上任务号的方式将这个被暂停了的进程以后台进程的方式继续运行;
Linux中的进程间关系
在这个会话中以后台进程的方式运行了两组进程,第一组进程直接调用上文代码的程序,即循环打印hello world
, 在这里以后台进程的方式运行,并将输出到标准输出的内容重定向到log1.txt
文件中,同时调用了sleep 1000 | sleep 2000 | sleep 3000
命令,并且带上&
表示以后台进程的方式运行;
在使用jobs
命令查看时可以查看到到当前会话下有两个后台进程;
可以使用ps axj
命令来查看这些进程更详细的一些信息;
cpp
$ ps axj | head -1 && ps axj | grep -Ei 'a.out|sleep' | grep -v grep
这一行命令分为两个部分,用&&
进行连接;
第一部分ps axj | head -1
表示打印出详细信息的头部;
第二部分ps axj | grep -Ei 'a.out|sleep' | grep -v grep
中的ps axj
同上,显示所有进程的状态,包含作业控制信息,grep -Ei 'a.out|sleep'
中:
-
grep
全局正则表达式打印工具,用于匹配文本中的模式;
-
-E
这个选项表示使用扩展正则表达式;
-
-i
这个选项表示忽略大小写;
这会筛选出命令中包含a.out
或sleep
的进程行,与用户运行的特定进程相关;
其中grep -v grep
表示再次过滤包含grep
关键字的行,以排除当前正在执行的命令自身信息(因为在上一步过滤时,实际的grep
命令行也会被返回);
其中PPID
表示该进程的父进程ID,PID
表示该进程的ID,PGID
则表示当前进程所属进程组的ID;
SID
表示为当前会话的ID,全称为session id
,TTY
表示当前对应的终端是谁;
当一个用户登录这个Linux系统时系统将会为这个用户创建一个会话session
,一个系统中可能存在多个会话,那么这么多的会话将通过"先描述后组织"的方式被进行管理,大致方式为为会话建立一个对应的数据结构,将这些数据结构以一定的容器将其进行保存,其中可以通过这个SID
来间接获取会话的大致信息;
从这个例子可以看出独立运行的a.out
的这个进程是自成一个进程组的,而三个sleep
构成一个进程组;
其中一个进程组对应的实际上是一个任务,这意味着一个任务可能被多个进程执行,其中./a.out
是一个任务,这个任务单独由PID
为32031
的进程执行;
sleep 1000 | sleep 2000 | sleep 3000
也是一个任务,这个任务由三个进程执行;
进程PID
,与进程组PGID
相同的进程为这个进程组的组长进程,通常情况下进程组组长为多个进程中的第一个进程;
-
进程组和任务的关系
在早期较为狭义的解释来说,一个进程就是一个任务,所以在Linux中进程控制块的名称为
task_struct
,其中task
的翻译为任务的意思;但实际上来说任务是需要指派给进程组的,一个任务可以单独由一个进程完成(一个进程自成一组);
一个任务也可以由多个进程完成(多个进程为一个进程组);
其中上文中使用
jobs
命令所显示的任务列中的任务是需要指派给进程组的任务;任务是一个偏向于用户的概念,任务是需要被进程组完成,而进程组中的进程可以是一个也可以是多个;
所以上文中所述的前台进程和后台进程实际上应该被成为前台任务和后台任务;
多个任务若是在同一个会话中启动,那么他们的Session ID
是相同的,而对应SID
的进程实际上为bash
进程;
上面查看到两个进程组对应的SID
为31980
,使用ps axj
命令查看进程对应信息,实际上会话属于bash
;
所以本质上当一个用户登录操作系统时将会启动一个bash
,而后系统将会以bash
的PID
来创建一个会话,其中bash
的PID
将会被用来为这个会话命名;
实际上会话和进程组就是在一个进程PCB结构体中利用指针来指向其他进程的PCB;
本次登录中一共存在六个会话,对应的也存在六个bash
,从图中可以看出,实际上bash
既承担着一次会话,同时也是一个自成一组的进程;
守护进程
这里有两个会话,其中一个会话以后台任务的方式执行三个任务,另一个会话使用ps -axj
命令查看对应的任务信息,其中SID
为32587
的会话为右边的会话;
此时若是将对应的会话关闭再重新打开一个会话,对应的后台进程也会跟着消失;
通常情况下,当会话(如一个登录终端)结束时,与之关联的所有进程(包括后台任务)都会收到SIGHUB
信号的影响,这个信号通常会导致这些进程终止;
在有些情况下对应的后台任务可能会被保留,但是其会被托孤给操作系统,即PPID
为1
,因为其对应的父进程会被终止;
说明后台任务是会受会话的退出影响的,也就是后台任务会受用户的登录和退出影响;
如果需要创建出一个完全不受任何用户登录和注销的影响的进程,则需要将他守护进程化;
所谓的守护进程就是一个进程自称为一个进程组与会话,当这个进程既自成一个进程组又自成一个会话,那么这个进程将不会受任何其他用户登录和注册的影响,那么这个进程就是守护进程;
守护进程也是后台进程中的一种,但是与普通后台进程不同,守护进程不受任何用户的登录和注销的影响;
通常调用setsid()
函数来将一个进程设置为一个守护进程;
bash
SETSID(2) Linux Programmer's Manual SETSID(2)
NAME
setsid - creates a session and sets the process group ID
SYNOPSIS
#include <unistd.h>
pid_t setsid(void);
RETURN VALUE
On success, the (new) session ID of the calling process is returned. On error, (pid_t) -1 is returned, and errno is set to indicate the error.
这个函数将创建一个会话,并且将调用该函数的进程单独放置在这个会话当中,自成一个会话同时自成一个进程组;
这个函数调用成功时将会返回新的会话ID
即Session ID - SID
,调用失败时则返回-1
并设置全局变量errno
错误码以标识错误原因;
在调用这个函数时应该注意,调用这个函数的进程不能是一个进程组的组长进程,而当一个进程单独运行时那么必然这个进程将自成一个组,并且这个进程将会成为组长进程,所以若是一个进程独立运行时可以利用fork()
创建子进程,子进程与父进程共同组成一个进程组,父进程必然是进程组的组长,而子进程是进程组的组员进程,当父进程创建成功后可以直接退出,而子进程则调用setsid()
函数将自己变成一个守护进程,最终子进程执行需要的服务;
守护进程的本质就是一个孤儿进程,因为守护进程的父进程在守护进程创建后将直接退出,与普通的孤儿进程不同,要被设置成守护进程的进程在被托孤后将调用setsid()
自成一个组且自成一个会话;
字典服务的守护进程化
要将字典服务进行守护进程化只需要添加一个小插件即可;
首先作为一个守护进程不能随意的被信号暂停,同时不能因为进程接收到的异常信号而崩溃退出,所以需要调用signal()
函数将对应的信号设置为忽略;
cpp
/* daemon.hpp */
void Daemon()
{
// 1.忽略异常信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
signal(SIGSTOP, SIG_IGN);
}
这里忽略了三个信号,分别为SIGCHLD
,SIGPIPE
和SIGSTOP
;
其中SIGCHLD
为防止产生僵尸进程,SIGPIPE
为防止服务端再向客户端进行写入途中连接被关闭而异常崩溃,SIGSTOP
则是防止某些用户使用SIGSTOP
信号暂停守护进程;
将可能出现的异常信号设置为忽略后即需要将自身变成独立的会话,变成独立的会话只需要进行一次fork()
,当子进程被成功创建后立即将父进程退出,子进程再调用setsid()
函数即可;
cpp
/* daemon.hpp */
void Daemon()
{
// 2.将自己变成独立的会话
if (fork() > 0)
exit(0);
setsid();
}
同时守护进程可能是一个服务,既然是一个服务那么可能需要将一些设置或者文件写入到操作系统中,那么则需要修改守护进程的工作目录,一个进程在运行时默认为在当前目录下运行,但是可以调用chdir()
函数来修改该进程的工作目录;
cpp
/* daemon.hpp */
void Daemon(const std::string &cwd = "")
{
// 3.更改当前调用进程的工作目录
if (!cwd.empty())
{
chdir(cwd.c_str());
}
}
这里将Daemon()
函数设置为了接收一个std::string&
的对象引用,用来传递可能需要修改的路径字符串;
同时服务端中可能出现大量的debug
消息,这些debug
消息与日志消息是打印在标准输出中的,而如果将这些信息全部打印在标准输出中将会影响服务端命令行整体的观感;
在Linux中位于/dev/null
文件是一个类似于回收站(垃圾桶)的文件,标准输入无法从这个文件中读取内容,但可以利用标准输出将文件写入到这个文件内表示为不需要的输出内容,如debug
所产生的信息,对应的如果需要保存日志信息,可以在自定义日志类对象中修改日志信息的输出方式(如输出在文件当中);
cpp
/* daemon.hpp */
const std::string nullfile = "/dev/null";
void Daemon(const std::string &cwd = "")
{
// 4.标准输入 标准输出 标准错误重定向至/dev/null中
int fd = open(nullfile.c_str(), O_RDWR); // 以读写方式打开文件
if (fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
当这个插件被设计好后只需要在服务端中的Start()
函数中调用该函数则可以将该服务设置成为守护进程;
cpp
class TcpServer
{
public:
void Start()
{
Daemon(); // 调用守护进程插件
ThreadPool<Task>::getInstance()->Start();
// 获取连接 处理客户端请求
lg(INFO, "TcpServer start sucess...");
while (true)
{
// 获取连接
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
socklen_t len = sizeof(local);
int sockfd = accept(listen_sockfd_, (struct sockaddr *)&local, &len);
if (sockfd < 0)
{
lg(WARNING, "accept error , error message: %s", strerror(errno));
continue;
}
uint16_t client_port = ntohs(local.sin_port);
char ipstr[32];
inet_ntop(AF_INET, &(local.sin_addr), ipstr, sizeof(ipstr));
lg(INFO, "accept sucess , sockfd :%d ,client ip :%s ,client port :%d",
sockfd, ipstr, client_port);
Task t(sockfd, ipstr, client_port);
ThreadPool<Task>::getInstance()->Push(t);
}
}
};
测试
运行服务端后并不会打印过多消息,唯一一条的INFO
日志信息为服务端成为守护进程之前的日志信息;
运行服务端后运行客户端,服务正常运行,可以使用netstat
命令查看当前网络状态;
也可以使用ps axj
命令来查看该进程的进程信息;
bash
ps axj | head -1 && ps axj | grep ./server | grep -v grep | grep -v vscode
可以看到这个进程的PID
,PGID
和SID
相同,说明这个进程自成一个组,并且自成一个会话,进程的PPID
为1
,说明进程是一个孤儿进程,进程的TTY
为?
说明进程不属于任何一个终端;
将所有会话关闭,并重新另起一个会话,通过ps axj
命令查看之前运行的服务端,服务仍在运行,不受任何用户登录与注销的影响;
系统自带的守护进程化
系统自带的守护进程化接口为daemon()
;
bash
NAME
daemon - run in the background
SYNOPSIS
#include <unistd.h>
int daemon(int nochdir, int noclose);
Feature Test Macro Requirements for glibc (see feature_test_macros(7)):
daemon(): _BSD_SOURCE || (_XOPEN_SOURCE && _XOPEN_SOURCE < 500)
RETURN VALUE
(This function forks, and if the fork(2) succeeds, the parent calls _exit(2), so that further errors are seen by the child only.) On success daemon() returns zero. If an error
occurs, daemon() returns -1 and sets errno to any of the errors specified for the fork(2) and setsid(2).
daemon - run in the background
表示在后台运行;
-
nochdir
这个参数表示进程是否需要更改工作目录,如果传入
0
则表示将进程的工作目录修改为根目录,否则进程的工作目录为当前目录; -
noclose
这个参数表示是否需要把标准输入,标准输出和标准错误重定向到
/dev/null
文件中,如果传入0
则表示需要,否则标准输入,标准输出和标准错误将不会被重定向到/dev/null
文件中(不动);
可以根据情况选择自己定义一个特定的守护进程化模块或者使用系统自带的守护进程化接口;
TCP的三次握手和四次挥手
TCP协议是一种面向连接的协议,在建立连接时采用的是三次握手的的方式,当连接断开时采用四次挥手的方式;
三次握手是为了确保双方建立可靠连接的一种协议过程,四次挥手则是用于安全关闭已连接的操作;
-
三次握手
-
第一次握手
客户端发送一个
SYN
报文给服务器,并进入SYN_SENT
状态,该保温包含了客户端的初始序列号(ISN
); -
第二次握手
服务器接收到
SYN
报文,发送了一个SYN-ACK
报文作为回应,该报文包含服务器的初始序列号,同时对客户端的SYN
请求进行确认(ACK(x+1)
),此时服务器进入SYN_RCVD
状态; -
第三次握手
客户端接收到
SYN-ACK
报文后,发送一个确认报文ACK
给服务器,表示确认接收到了服务器响应,携带ACK(y+1)
;客户端进入
ESTABLISHED
状态,同时服务器收到ACK
后也进入ESTABLISHED
状态;
这样就完成了三次握手;
-
-
四次挥手
-
第一次挥手
主动关闭方(一般是客户端)发送一个
FIN(finish)
报文给对方,并进入FIN_WAIT_1
状态; -
第二次挥手
被动关闭方(一般是服务器)收到
FIN
报文后,发送一个ACK
报文进行确认,并进入CLOSE_WAIT
状态;此时主动关闭放进入
FIN_WAIT_2
状态; -
第三次挥手
被动关闭方准备好关闭连接时,发送一个
FIN
报文给主动关闭放并进入LAST_ACK
状态; -
第四次挥手
主动关闭放收到
FIN
报文后,发送最后一个ACK
报文进行确认,然后进入TIME_WAIT
状态,这个状态会持续一段时间以确保接收方接收到最后的ACK
;被动关闭方在收到这个
ACK
后进入关闭(CLOSE
)状态;
-
TCP 的全双工
全双工通信指的是通信双方在同一个时间点内既可以发送数据也可以接收数据,与半双工不同,半双工要求发送和接收过程不能同时进行;
TCP的全双工本质上是TCP协议提供了两个缓冲区,分别为发送缓冲区和接收缓冲区,其中发送缓冲区;
当TCP套接字被创建好后将会默认创建对应的缓冲区,这个缓冲区实际上是两块内存空间;
当建立网络连接后必然是存在一个服务端和客户端的,对应的客户端将数据从应用层写入至发送缓冲区中,发送缓冲区的数据通过网络传输给服务端的接收缓冲区,对应的服务端在对客户端进行响应时也将响应写入至自己的发送缓冲区中,发送缓冲区中的响应通过网络发送给客户端的接收缓冲区中;
参考代码
-
仓库地址(供参考)