【Linux】TCP的服务端(守护进程) + 客户端

文章目录

  • [📖 前言](#📖 前言)
  • [1. 服务端基本结构](#1. 服务端基本结构)
    • [1.1 类成员变量:](#1.1 类成员变量:)
    • [1.2 头文件](#1.2 头文件)
    • [1.3 初始化:](#1.3 初始化:)
      • [1.3 - 1 全双工与半双工](#1.3 - 1 全双工与半双工)
      • [1.3 - 2 inet_aton](#1.3 - 2 inet_aton)
      • [1.3 - 3 listen](#1.3 - 3 listen)
  • [2. 服务端运行接口](#2. 服务端运行接口)
    • [2.1 accept:](#2.1 accept:)
    • [2.2 服务接口:](#2.2 服务接口:)
  • [3. 客户端](#3. 客户端)
    • [3.1 connect:](#3.1 connect:)
    • [3.2 客户端的实现:](#3.2 客户端的实现:)
  • [4. 提供服务](#4. 提供服务)
    • [4.1 单进程版本:](#4.1 单进程版本:)
    • [4.2 多进程1.0版本:](#4.2 多进程1.0版本:)
    • [4.3 多进程1.1版本:](#4.3 多进程1.1版本:)
    • [4.4 多线程2.0版本:](#4.4 多线程2.0版本:)
    • [4.5 线程池3.0版本:](#4.5 线程池3.0版本:)
    • [4.6 执行客户端指令:](#4.6 执行客户端指令:)
  • [5. 守护进程](#5. 守护进程)
    • [5.1 进程组&&会话:](#5.1 进程组&&会话:)
    • [5.2 引入守护进程:](#5.2 引入守护进程:)
    • [5.3 实现:](#5.3 实现:)
    • [5.4 守护进程化的剩余两种方法:](#5.4 守护进程化的剩余两种方法:)

📖 前言

上一节,我们用了udp写了一个服务端和客户端之间通信的代码,只要函数了解认识到位,上手编写是很容易的。

本章我们开始编写tcp的服务端和客户端之前通信的代码,要认识一批新的接口,并将我们之前学习的系统知识加进来,做到融会贯通...

代码详情:👉 Gitee


1. 服务端基本结构

对于TCP服务器和UDP服务器的初始化接口,确实有一些相似之处,但是它们在选择字节流进行初始化方面存在一些区别。

  • 首先,无论是TCP服务器还是UDP服务器,都需要进行套接字的创建、绑定和监听操作。这些初始化步骤是相同的。
  • 区别在于,TCP服务器使用字节流(byte stream) 进行数据传输,而UDP服务器使用数据报(datagram) 进行数据传输。
  • 对于UDP协议,任何人都可以向服务器发送数据报,而且不需要等待服务器响应。UDP协议是无连接的传输协议,数据报发送出去后就结束。
  • TCP协议是面向连接的传输协议,需要先建立连接才能进行数据传输,并且在连接建立、数据传输和断开连接的过程中需要互相响应。

1.1 类成员变量:

cpp 复制代码
class Task
{
	// ....
	
private:
    int sock_;        // 给用户提供IO服务的sock
    uint16_t port_;   // client port
    std::string ip_;  // client ip
    callback_t func_; // 回调方法
};

1.2 头文件

因为每个源文件都要包好多相同的头文件,所以我们将要用到的头文件一并打包在一个头文件里:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <cassert>
#include <ctype.h>
#include <unistd.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"

#define SOCKET_ERR 1
#define BIND_ERR   2
#define LISTEN_ERR 3
#define USAGE_ERR  4
#define CONN_ERR   5

#define BUFFER_SIZE 1024

一般涉及到struct sockaddr_in,都要包含这两个头文件:

1.3 初始化:

TCP是面向字节流的:

1.3 - 1 全双工与半双工

全双工(Full Duplex)和半双工(Half Duplex)是通信中两种不同的传输模式:

  • 全双工是指通信双方可以同时进行双向的数据传输。
    • 在全双工模式下,通信双方的发送和接收操作是独立进行的,彼此之间不会互相干扰。
    • 这种模式可以实现实时的双向通信,类似于我们平时打电话或进行视频通话时的交流方式。
  • 半双工是指通信双方在同一时间内只能进行单向的数据传输。
    • 在半双工模式下,通信双方轮流地进行发送和接收操作,不能同时进行。
    • 当一方发送数据时,另一方只能等待接收,反之亦然。
    • 这种模式类似于对讲机的使用方式,一方讲话时,另一方只能听取,无法即时回应。

套接字和管道:

  • 管道只能通过一个文件描述符读,一个文件描述符写,所以叫做单向管道。
  • 而在TCP中读写用的都是一个套接字fd,UDP在读写时用的也是一个套接字。
  • TCP/UDP都支持全双工。

1.3 - 2 inet_aton

cpp 复制代码
int inet_aton(const char *cp, struct in_addr *inp);
  • 它的作用是将一个点分十进制的IP地址字符串(cp)转换为网络字节序的二进制数,并将结果存储在in_addr结构体(inp)中。
  • 因此,inet_aton函数的第一个参数是要转换的IP地址字符串,第二个参数是存储转换结果的结构体指针。
  • 函数的返回值是一个整数,表示转换是否成功。如果转换成功,返回值为非零;如果转换失败,返回值为零。

1.3 - 3 listen

listen函数用于将一个已经建立连接的套接字(通常是一个服务端的套接字)标记为被动模式,开始监听来自客户端的连接请求。

它接受两个参数:sockfd是要设置为被动模式的套接字文件描述符,backlog是指定等待连接队列的最大长度。

accept第一个参数监听到了之后,然后返回一个值之后,再继续去监听。

listen的第二个参数我们以后再讲...

监听socket,为何要监听呢?

  • 因为udp是无连接的(通信可以,但是不用建立连接,直接发消息就可以了),而tcp是面向连接的!
  • 面向就是在做任何事之前要先干什么这就是面向的意思,面向连接就是在做其他工作之前先把连接建立好。
  • 不管有没有客户端连接,得让服务器将来任何时候被别人连接,所以要将套接字设置成监听状态。

下面的初始化就和之前udp的初始化大差不差了...

cpp 复制代码
void init()
{
    // 1. 创建socket
    listenSock_ = socket(PF_INET, SOCK_STREAM, 0);
    if (listenSock_ < 0)
    {
        logMessage(FATAL, "socket: %s", strerror(errno));
        exit(SOCKET_ERR);
    }
    logMessage(DEBUG, "socket: %s, %d", strerror(errno), listenSock_);

    // 2. bind绑定
    // 2.1 填充服务器信息
    struct sockaddr_in local; // 用户栈
    memset(&local, 0, sizeof local);
    local.sin_family = PF_INET;
    local.sin_port = htons(port_);
    ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr));
    // 2.2 本地socket信息,写入sock_对应的内核区域
    if (bind(listenSock_, (const struct sockaddr *)&local, sizeof local) < 0)
    {
        logMessage(FATAL, "bind: %s", strerror(errno));
        exit(BIND_ERR);
    }
    logMessage(DEBUG, "bind: %s, %d", strerror(errno), listenSock_);

    // 3. 监听socket,为何要监听呢?tcp是面向连接的!
    if (listen(listenSock_, 5 /*后面再说*/) < 0)
    {
        logMessage(FATAL, "listen: %s", strerror(errno));
        exit(LISTEN_ERR);
    }
    logMessage(DEBUG, "listen: %s, %d", strerror(errno), listenSock_);
    // 走到这就意味着允许别人来连接你了

    // 4. 加载线程池
    // tp_ = ThreadPool<Task>::getInstance();
}

2. 服务端运行接口

2.1 accept:

accept函数用于接受客户端连接的请求。它被用于一个已经处于被动监听状态的套接字(通常是服务端的套接字)。

当有新的客户端连接请求到达时,accept函数将会返回一个新的套接字文件描述符,此后服务端就可以通过这个新的套接字与客户端进行通信。

  • sockfd表示要接受连接的套接字文件描述符。
  • addr指向保存客户端地址信息的结构体指针(可以传入NULL)。
  • addrlen表示addr结构体的长度。

后面两个参数和recvfrom后两个参数的含义一模一样,是想拿到是哪个客户端连接的。

  • 第一个参数sockfd是套接字描述符: 用来获取新连接的套接字,叫做监听socket
  • 这个监听套接字负责监听指定的网络地址和端口,等待客户端的连接请求。
  • 返回值是一个套接字描述符: 主要是为用户提供网络服务的socket,主要是进行IO
  • 当有客户端发起连接请求时,accept()函数就会返回一个新的套接字。
  • 这个新的套接字与客户端的套接字建立连接,用于后续的数据传输。

accept函数的阻塞:

  • accept函数是在网络编程中用于接受客户端连接的函数。
  • 当调用accept函数时,如果有客户端连接请求到达,它会立即返回一个新的套接字来与该客户端进行通信。
  • 如果没有客户端连接请求到达,accept函数将会阻塞,即一直等待直到有新的连接请求到达为止。

在阻塞状态下,程序会停止执行后续代码,直到有新的连接请求到达或者发生错误。因此,可以将accept函数放在一个循环中,反复接受多个客户端连接。需要注意的是,在某些情况下,可以通过设置套接字为非阻塞模式来避免accept函数的阻塞,这样程序可以继续执行其他操作。

cpp 复制代码
void loop()
{
    tp_->start();
    logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum());

    while (true)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        // 4. 获取连接, accept 的返回值是一个新的socket fd ??
        // 4.1 listenSock_: 监听 && 获取新的链接-> sock
        // 4.2 serviceSock: 给用户提供新的socket服务
        int serviceSock = accept(listenSock_, (struct sockaddr *)&peer, &len);
        if (serviceSock < 0)
        {
            // 获取链接失败
            logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock);
            continue;
        }

        // 4.1 获取客户端基本信息
        uint16_t peerPort = ntohs(peer.sin_port);
        std::string peerIp = inet_ntoa(peer.sin_addr);

        logMessage(DEBUG, "accept: %s | %s[%d], socket fd: %d",
                   strerror(errno), peerIp.c_str(), peerPort, serviceSock);
                   
        // 提供服务....
    }
}

2.2 服务接口:

提供的服务,将小写转成大写:

cpp 复制代码
// 大小写转化服务
// TCP && UDP: 支持全双工
void transService(int sock, const std::string &clientIp, uint16_t clientPort)
{
    assert(sock >= 0);
    assert(!clientIp.empty());
    assert(clientPort >= 1024);

    char inbuffer[BUFFER_SIZE];
    while (true)
    {
        ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为我们读到的都是字符串
        if (s > 0)
        {
            // read success
            inbuffer[s] = '\0';
            if (strcasecmp(inbuffer, "quit") == 0)
            {
                logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                break;
            }
            logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);

            // 可以进行大小写转化了
            for (int i = 0; i < s; i++)
            {
                if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
                    inbuffer[i] = toupper(inbuffer[i]);
            }
            logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer);

            write(sock, inbuffer, strlen(inbuffer));
        }
        else if (s == 0)
        {
            // pipe: 读端一直在读,写端不写了,并且关闭了写端,读端会如何?s == 0,代表对端关闭
            // s == 0: 代表对方关闭,client 退出
            logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
            break;
        }
        else
        {
            logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
            break;
        }
    }

    // 只要走到这里,一定是client退出了,服务到此结束
    close(sock); 
    logMessage(DEBUG, "server close %d done", sock);
}

recvfromsendto是专门针对udp发送用户数据报的,它是一 个固定大小的报文,在那里它是专函数专用的,专门为udp提供的。而tcp就通用的多,因为tcp是流式服务,我们这里直接可以当做是处理文件的方式来进行读写。

如果一个进程对应的文件fd,打开了没有被归还,这种现象叫做文件描述符泄漏!

  • 如果不关,来一个客户端打开一个文件描述符,会导致该服务端进程可用文件描述符越来越少。
  • 文件描述符表是有上限的,时间一久,会导致服务器无法获取新连接,申请文件描述符时发现所有文件描述符都被占用了。
  • 此时服务器就无法对外提供服务了。

3. 客户端

3.1 connect:

connect是一个系统调用函数,用于建立与远程主机的连接。它通常用于创建客户端套接字,并将其连接到服务器套接字。

  • sockfd:套接字文件描述符,由socket函数创建获得。
  • addr:指向远程主机的地址结构体的指针,可以是struct sockaddr_instruct sockaddr_in6
  • addrlen:远程主机地址结构体的长度。

connect 会自动帮我们进行bind!

connect函数通过sockfdaddr参数指定的地址信息,将本地套接字与远程主机的套接字连接起来。如果连接成功,返回0;如果连接失败,返回-1,并设置全局变量errno表示错误类型。


注意:在使用connect函数之前,必须先创建一个套接字,并确保套接字是可用的,可以使用socket函数进行创建。

三个问题:

  • 客户端需要绑定吗?需要但是不需要自己显示的bind!
  • 需要监听吗?不需要,监听是让别人来连你,作为客户端不用被连!
  • 需要accept吗?都没人来连你,根本不需要获取连接!

3.2 客户端的实现:

有了上面的分析,再加上之前udp编写的基础,我们很容易就能将tcp的客户端编写完成:

cpp 复制代码
#include "util.hpp"

volatile bool quit = false;

static void Usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " serverIp serverPort" << std::endl;
    std::cerr << "Example:\n\t" << proc << " 127.0.0.1 8081\n"
              << std::endl;
}

// ./clientTcp serverIp serverPort
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }

    std::string serverIp = argv[1];
    uint16_t serverPort = atoi(argv[2]);

    // 1. 创建socket SOCK_STREAM
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        std::cerr << "socket: " << strerror(errno) << std::endl;
        exit(SOCKET_ERR);
    }

    // 2. connect,发起链接请求,你向谁发起请求呢??当然是向服务器发起请求喽!
    // 2.1 先填充需要连接的远端主机的基本信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof server);
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    inet_aton(serverIp.c_str(), &server.sin_addr);

    // 2.2 发起请求,(隐性的概念)connect 会自动帮我们进行bind!
    if (connect(sock, (const struct sockaddr *)&server, sizeof server) != 0)
    {
        std::cerr << "connect: " << strerror(errno) << std::endl;
        exit(CONN_ERR);
    }
    std::cout << "info : connect success: " << sock << std::endl;

    std::string message;
    while (!quit)
    {
        message.clear();
        std::cout << "请输入你的消息>>> ";
        std::getline(std::cin, message);
        
        if (strcasecmp(message.c_str(), "quit") == 0)
            quit = true;

        // 向服务器发消息
        ssize_t s = write(sock, message.c_str(), message.size());
        std::cout << "read before" << std::endl;
        
        if (s > 0)
        {
            message.resize(1024);
            ssize_t s = read(sock, (char *)(message.c_str()), 1024);

            if (s > 0)  message[s] = 0;
            std::cout << "Server Echo>>> " << message << std::endl;
        }
        else if (s <= 0)
        {
            break;
        }
    }

    close(sock);
    return 0;
}

日志重定向:

之前我们将日志全部都打印在显示器上,这次我们将日志全部都打印到一个文件中,方便以后查看:

客户端连接服务器:


4. 提供服务

4.1 单进程版本:

cpp 复制代码
// 提供服务, echo -> 小写 -> 大写
// 0.0 版本 -- 单进程 -- 一旦进入transService,主执行流,就无法进行向后执行,只能提供完毕服务之后才能进行accept

transService(serviceSock, peerIp, peerPort);

我们不重定向,方便我们进行实验。

实验结果:



如果ctrl + c杀掉客户端进程的话:

  • ctrl + c异常终止的话,文件是只有这个进程打开的,文件的生命周期是随进程的。
  • 如果强制的将客户端ctrl + c掉,操作系统会自动的关闭掉进程所对应的文件描述符。
  • 进程退出,PCB被文件释放,文件描述符表被释放,文件指针指向的struct file结构体引用计数减减。
  • 因为只有一个指向文件结构体,就减到0,操作系统自动关闭这个文件描述符。
  • 已关闭该文件,服务端读文件就会读到0,就类似于读到文件结束。

多个客户端连接服务器(有问题的):

我们发现一个客户端连接服务器的时候,客户端可以正常的显示出服务器处理过的结果。

但是,一旦我们有两个或者两个以上的客户端连接服务器就会出问题:新连接的客户端会卡在那里。


原因解释(看我笔记吧):

4.2 多进程1.0版本:

cpp 复制代码
// 1.0 版本 -- 多进程版本 -- 父进程打开的文件会被子进程继承吗?会的!
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
    close(listenSock_); // 建议关掉

    transService(serviceSock, peerIp, peerPort);
    exit(0); // 任务处理完就退出,进入僵尸
}
// 父进程 -- 父进程不用对外提供服务
close(serviceSock); // 这一步是一定要做的!
// waitpid(); 默认是阻塞等待!WNOHANG

(服务函数放在类内,类外都行)

  • 子进程也会把曾经父进程打开的listen套接字继承下去。
  • 通过创建子进程,让其去做父进程代码的一部分。
  • close(listenSock_);建议关掉。
    • 万一子进程将listenSock_文件描述符给写了,可能影响将来accept
  • close(serviceSock);这一步是一定要做的!
    • 如果父进程不关掉,那么随着连接来的客户端的增多,父进程可用的文件描述符就会越来越少。
    • 父进程获取servicSock文件描述符是为了让子进程继承下去,自己是不用的,就不应该继续占着,如果不关闭,最后可能导致文件描述符泄漏的问题。

我们知道子进程退出之后就会进入僵尸状态!等待父进程回收!
那我们敢让父进程阻塞式等待吗,显然是不能!因为我们的目的是让服务器并发起来,现在还阻塞着。

如果用非阻塞等待WNOHANG,这是可以的,我们要所有子进程的PID保存起来,非阻塞等待的时候每一次都要轮询所有的子进程,但是比较麻烦。👉 进程等待复习 - 传送门

或者直接忽略SIGCHLD

cpp 复制代码
// 不用等子进程了
// signal(SIGCHLD, SIG_IGN); // only Linux

忽略SIGCHLD,👉 复习传送门

4.3 多进程1.1版本:

cpp 复制代码
// 1.1 版本 -- 多进程版本  -- 这样写也是可以的
// 爷爷进程
pid_t id = fork();
if(id == 0)
{
    // 爸爸进程
    close(listenSock_);// 建议关掉
    // 又进行了一次fork,让 爸爸进程
    if(fork() > 0) exit(0);

    // 孙子进程 -- 就没有爸爸 -- 就变成了孤儿进程 -- 被系统领养 -- 孙子进程就交给了系统来回收
    transService(serviceSock, peerIp, peerPort);
    exit(0);
}

// 父进程
close(serviceSock); // 这一步是一定要做的!
// 爸爸进程直接终止,立马得到退出码,释放僵尸进程状态
pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式
assert(ret > 0);
(void)ret;
  • 我们这里用到了 爷爷、爸爸、孙子 三个进程。
  • 爷爷进程创建爸爸进程,爸爸进程再创建孙子进程。
  • 只不过爸爸进程在创建完孙子进程之后直接就退出,由爷爷进程对其进行回收。
  • 将服务任务交由孙子进程去做。

孙子进程,没有了父进程,就变成了孤儿进程,被系统领养,孙子进程就交给了系统来回收,就不用我们来回收了。

子进程是从fork函数开始执行的。👉 复习传送门

(服务函数放在类内,类外都行)

4.4 多线程2.0版本:

因为我们是线程函数是设置在类内的方法,所以成员函数第一个参数是隐藏的this指针,我们要设置成静态的。

静态成员函数里要想获取到类内成员变量的话,还要搞一些获取类内成员的接口,我们直接将现这些数据封装一下:

cpp 复制代码
// 先声明一下
class ServerTcp;

class ThreadData
{
public:
    uint16_t clientPort_;
    std::string clinetIp_;
    int sock_;
    ServerTcp *this_;

public:
    ThreadData(uint16_t port, std::string ip, int sock, ServerTcp *ts)
        : clientPort_(port), clinetIp_(ip), sock_(sock), this_(ts)
    {}
};

线程函数:

cpp 复制代码
// 类内方法,形参默认带有this指针
static void *threadRoutine(void *args)
{
    pthread_detach(pthread_self()); // 设置线程分离
    ThreadData *td = static_cast<ThreadData*>(args);
    td->this_->transService(td->sock_, td->clinetIp_, td->clientPort_);
    delete td;
    return nullptr;
}

(此时服务函数放在了类里面)

cpp 复制代码
// 2.0 版本 -- 多线程
ThreadData *td = new ThreadData(peerPort, peerIp, serviceSock, this);
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void*)td);
// 不可进行线程等待,一等待,主线程就阻塞了,只能用线程分离
  • 这里不需要进行关闭文件描述符吗??不需要啦!!
  • 多线程是会共享文件描述符表的!

不可进行线程等待(pthread_join),一等待,主线程就阻塞了,只能用线程分离。

4.5 线程池3.0版本:

Task任务需要我们重写:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include "log.hpp"

class Task
{
public:
    // 下面两个等价
    // typedef std::function<void (int, std::string, uint16_t)> callback_t;
    using callback_t = std::function<void (int, std::string, uint16_t)>;
public:
    Task():sock_(-1), port_(-1)
    {}
    Task(int sock, std::string ip, uint16_t port, callback_t func)
    : sock_(sock), ip_(ip), port_(port), func_(func)
    {}

    void operator () ()
    {
        logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 开始啦...",\
            pthread_self(), ip_.c_str(), port_);

        func_(sock_, ip_, port_);

        logMessage(DEBUG, "线程ID[%p]处理%s:%d的请求 结束啦...",\
            pthread_self(), ip_.c_str(), port_);
    }
    ~Task()
    {}
private:
    int sock_;        // 给用户提供IO服务的sock
    uint16_t port_;   // client port
    std::string ip_;  // client ip
    callback_t func_; // 回调方法
};

交给线程池处理:

cpp 复制代码
// 3.0 版本 -- 线程池
// transService服务在类外
Task t(serviceSock, peerIp, peerPort, transService);
tp_->push(t);

(服务函数放在类外)

我们在初始化服务器的方法的最后,加了一个启动线程池。 👉 线程池 - 复习
还需要再loop函数循环之前,将线程池中的线程加载好。

我们将服务方法通过Task打包封装一下加载进线程池当中,然后Task有个仿函数里面就是调用回调函数。

之前我们在学C++11的时候,学过bind,我们这里可以用起来:

cpp 复制代码
Task t(serviceSock, peerIp, peerPort, std::bind(&ServerTcp::transService, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
tp_->push(t);

bind不熟悉的看过来,👉 复习传送门

(服务函数放在类内)

4.6 执行客户端指令:

popen函数:

第一件事情,创建管道,第二件事情,fork会自动帮我们创建子进程,让子进程去执行command代码,子进程执行完了之后,让父进程通过文件能够读到结果。

具体来说,popen函数会创建一个管道,其中写入端口(write end)被父进程保留,而读出端口(read end)被子进程保留。然后,popen函数调用fork创建一个新的子进程,该子进程会继承父进程的文件描述符,包括管道的读写端口。匿名管道用于在父进程和子进程之间进行双向通信。

cpp 复制代码
void execCommand(int sock, const std::string &clientIp, uint16_t clientPort)
{
    assert(sock >= 0);
    assert(!clientIp.empty());
    assert(clientPort >= 1024);

    char command[BUFFER_SIZE];
    while (true)
    {
        ssize_t s = read(sock, command, sizeof(command) - 1); // 我们认为我们读到的都是字符串
        if (s > 0)
        {
            command[s] = '\0';
            logMessage(DEBUG, "[%s:%d] exec [%s]", clientIp.c_str(), clientPort, command);

            // 考虑安全
            std::string safe = command;
            if ((std::string::npos != safe.find("rm")) || (std::string::npos != safe.find("unlink")))
            {
                break;
            }

            // 我们是以r方式打开的文件,没有写入
            // 所以我们无法通过dup的方式得到对应的结果
            FILE *fp = popen(command, "r");
            if (fp == nullptr)
            {
                logMessage(WARINING, "exec %s failed, beacuse: %s", command, strerror(errno));
                break;
            }

            char line[1024];
            while (fgets(line, sizeof(line) - 1, fp) != nullptr)
            {
                write(sock, line, strlen(line));
            }

            pclose(fp);
            logMessage(DEBUG, "[%s:%d] exec [%s] ... done", clientIp.c_str(), clientPort, command);
        }
        else if (s == 0)
        {
            // pipe: 读端一直在读,写端不写了,并且关闭了写端,读端会如何?s == 0, 代表对端关闭
            // s == 0: 代表对方关闭,client 退出
            logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
            break;
        }
        else
        {
            logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
            break;
        }
    }

    // 只要走到这里,一定是client退出了,服务到此结束
    close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,文件描述符泄漏!
    logMessage(DEBUG, "server close %d done", sock);
}

同样的也是通过线程池的方式提供服务:

cpp 复制代码
Task t(serviceSock, peerIp, peerPort, execCommand);
tp_->push(t);

(服务函数放在类外)

备注:

如果我们设置了对应的任务是死循环,那么线程池提供服务,就显得有不太合适了,我们应该给线程池抛入的任务是短任务。


5. 守护进程

5.1 进程组&&会话:

进程组:

进程组是一个或多个相关联的进程的集合。在Linux操作系统中,进程组是进程管理的一种机制,用于管理共享相同终端或终端会话的一组进程。

进程组组长:

  • 每个进程组都有一个组长进程(Group Leader Process),它的进程ID与进程组ID相同。
  • 组长进程可以通过调用setpgid()系统调用将其他进程加入到自己的进程组中。
  • 组长进程通常是第一个创建进程组的进程,或者通过调用setpgid()将其他进程加入到自己所在的进程组中的进程。
  • 组长进程拥有一些特殊的权限和责任,例如可以向整个进程组发送信号、管理进程组的终止状态等。* 同时,组长进程也有责任确保该进程组中的所有进程得到正确的处理和管理。

需要注意的是:

  • 一个进程可以同时属于多个进程组,但一个进程只能担任一个进程组的组长。
  • 当组长进程终止时,其所在的进程组中的所有进程都将收到SIGHUP(hangup)信号,除非它们已经忽略了该信号或者通过调用signal()函数将其重置。

会话:

当用户登录到Linux系统后,系统为其创建一个会话(由多个进程组构成),并为其分配一个唯一的会话标识符(Session ID)。这个会话可以持续到用户注销或与系统断开连接为止。


首进程:

  • 在一个会话(Session) 中,首进程(Session Leader)是创建该会话的进程。
  • 当一个新的会话被创建时,通常由一个特定的进程作为首进程。
  • 首进程负责创建并管理该会话中的其他进程。

首进程具有以下特点:

  1. 首进程是会话的领导者,它拥有该会话的控制权和权限。首进程的进程ID (PID) 与会话ID (SID) 相同。
  2. 首进程可以创建或终止会话中的其他进程,并对它们进行管理和控制。
  3. 首进程通常是用户登录系统后启动的shell进程(如Bash、Zsh、 Csh 等), 它会创建一个新的会话并成为该会话的首进程。
  4. 首进程还负责设置会话的相关属性,如控制终端、信号处理等。
  5. 如果首进程退出或终止,整个会话将结束,会话中的所有其他进程也会被终止。
  • 如果我们自己在新启进程或者启动进程组,一定是属于bash自己的会话。

需要注意的是:

  • 一个会话可以包含多个进程组,而首进程只是会话中的一个特定进程, 它并不一定是进程组的领导者。
  • 进程组的领导者可以通过调用setpgid()来改变自己所属的进程组,但这并不会影响首进程的身份和权限。

我们平时使用电脑卡的原因可能是,因为在本次登录过程中,起了很多个任务,这些任务都属于同一个会话,所以在卡的时候,进行注销操作,本质是将会话内部所有进程组全部删掉。

  • 任何时刻,只能有一个前台进程组,而且必须要有一个前台进程组,有0个或者多个后台进程组。

我们将一个任务启动到前台,bash命令行解释器自己就将自己投递到后台了,就没有办法接收输入了。所以我们再次输入指令时,就不会有任何响应。

会话&&进程组,举个栗子:

起了三个进程,其中第一个进程一般都是进程组的组长。SID是当前进程的会话ID。三个进程会话属于同一个,就是bash。

bash自己就是个进程,自己就是组长,就是会话当中的话首进程,自成一组。

当bash启动时,它会成为一个新的进程,并且作为会话的首进程。Bash进程的进程ID(PID)和进程组ID(PGID)会话ID(SID)通常是相同的。

5.2 引入守护进程:

我们提供的网络服务能不能属于bash这会话呢?

  • 比如在登录的状态新起了一个网络服务器,创建好之后,再派生子进程也属于当前会话。
  • 所以就不能让网络服务器属于当前会话内容,要不然会受会话的用户登录和注销的影响(不一定会退出)。

所有会话内的进程fork创建子进程,一般而言依旧属于当前会话!!

当我们登录时,以bash为首给我们构建一个会话,在bash命令中可以输入各种前台任务,或者取地址变成后台任务,变成后台进程。

后台进程任务依旧属于当前会话,登录登出可能会影响当前进程组。

起一个后台任务,退出,然后重新登录,这个后台任务可能还在,是会受到登录登出的影响的,所以要设置新的会话。

  • 当我们有网络服务的时候就应该脱离会话,让它独立在计算机里让其形成自己的新会话。

自成进程组,自成会话的周而复始运行的这一类进程,我们称之为:守护进程 or 精灵进程。

一般以服务器的方式工作,对外提供服务的服务器,都是以守护进程(精灵进程)的方式在服务器中工作的,一旦启动之后,除非用户主动关闭,否则一直会在运行。

5.3 实现:

谁调用这个函数,谁就成为一个新的会话,并且成为新会话内进程组的组长。

返回值:

哪个进程调用它,哪个进程的pid会被返回,失败就返回-1,并且错误码被设置。

但是有个要求,调用setsid的这个进程,不能是进程组的组长,进程组组长一调用setsid,调用就失败。

注意:

  • 必须调用一个函数setsid():将调用进程设置成为独立的会话。
  • 进程组的组长,不能调用setsid();
  • 我如何不成为组长呢?
    • 可以成为进程组内的第二个进程!
    • 常规做法,fork()子进程,子进程就不再是组长,就可以调用setsid()了。

管道:如果写端一直在写,读端关闭,写端会被终止,被信号终止SIGPIPE

  • 如果server给client写入,但是client已经关了,就相当于向一个不存在的文件描述符写入,那么此时会出现什么问题呢?
    • server也会收到SIGPIPE信号!!
  • 当进程收到SIGPIPE信号时,如果未对该信号进行处理,进程将以异常退出的形式终止。

所以,我们忽略掉SIGPIPE信号,当然这只是选做。

cpp 复制代码
#pragma once

#include <cstdio>
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

void daemonize()
{
    int fd = 0;
    // 1. 忽略SIGPIPE
    signal(SIGPIPE, SIG_IGN);

    // 2. 更改进程的工作目录
    // chdir();

    // 3. 让自己不要成为进程组组长
    if (fork() > 0)
        exit(0); // 父进程退出了管都不用管,因为父进程有自己的父进程(bash)

    // 4. 设置自己是一个独立的会话 -- 不受登录退出的影响
    setsid();

    // 5. 重定向0, 1, 2
    if ((fd = open("/dev/null", O_RDWR)) != -1) // fd == 3
    {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);

        // 6. 关闭掉不需要的fd -- 别浪费了就关掉了
        if(fd > STDERR_FILENO)
            close(fd);
    }

    // 3, 4, 5这三步,是网络服务器写守护进程时,必写的三步

    // close(0,1,2);这种做法严重不推荐
}

重定向的原因:

daemonize();函数是在网络服务器启动之前调用的。

  • 那么serverTcp服务器里一定有大量的cout cerr打印日志的行为。
  • 我们调用setsid,创建一个会话,已经和终端没有关系了。
  • 一个会话绑定上终端(命令行界面),是一个字符式的模拟终端。
  • Linux中是bash进程和终端强关联,终端也是文件是被打开的,当前的进程就可以从终端里读,向终端里写入。
  • 一旦将进程转变为守护进程,它就会与终端断开关联。
  • 也就是说它不再与终端会话相关联,这意味着守护进程不再受用户登录或注销的影响,并且不会与用户直接交互。
  • 在setsid里就自动将进程和终端去关联了,去关联之后就不能使用cin cout cerr。
  • 因为0,1,2描述符已经和终端去关联了,后续程序一写就退出了。
  • 一旦重定向之后拦截输入输出,只是放在dev/nul/里丢弃掉了。
    • dev/nul/是Linux中的数据垃圾桶,或者叫信息黑洞。

在一些早期的Unix系统中,关闭0、1、2代表关闭了所有与终端相关的文件描述符,这样就成为了守护进程的一种惯例做法。但是在现代系统中,关闭0、1、 2是一种错误的实践,因为它们可能被其他进程使用,而且这些文件描述符对于守护进程的运行是非常重要的。这样可以避免程序在后续的执行中,因为无法访问标准流而出现问题,并确保它能够正常地向用户提供服务。

效果:

守护进程在命名时通常以d结尾。

5.4 守护进程化的剩余两种方法:

  • 第二种方法:

如果不想手动写守护进程,系统自带了函数接口:

  • 第三种方法:nohup:
  • 自己自成一个进程组,当前的会话依旧是属于3154,这个进程依旧是在本会话内部。
  • 形成的一个并非是守护进程,但是已经和守护进程是一样的了。
  • 虽然依就是属于3154这个会话,但是设置了nohup,就是不受用户登录和注销的影响了。

3154就是个bash:


退出后再登录:

此时已经是一个独立的会话了,成了一个孤儿进程。

相关推荐
虾..8 小时前
Linux 软硬链接和动静态库
linux·运维·服务器
Evan芙9 小时前
Linux常见的日志服务管理的常见日志服务
linux·运维·服务器
hkhkhkhkh12310 小时前
Linux设备节点基础知识
linux·服务器·驱动开发
HZero.chen11 小时前
Linux字符串处理
linux·string
张童瑶12 小时前
Linux SSH隧道代理转发及多层转发
linux·运维·ssh
汪汪队立大功12312 小时前
什么是SELinux
linux
石小千12 小时前
Linux安装OpenProject
linux·运维
柏木乃一12 小时前
进程(2)进程概念与基本操作
linux·服务器·开发语言·性能优化·shell·进程
Lime-309012 小时前
制作Ubuntu 24.04-GPU服务器测试系统盘
linux·运维·ubuntu
百年渔翁_肯肯12 小时前
Linux 与 Unix 的核心区别(清晰对比版)
linux·运维·unix