「前言」本文的内容大致是Linux任务管理与守护进程和TCP协议通讯流程。

五、增加日志功能

文章续上篇文章的内容,给服务器增加日志功能,即把打印到显示台的内容,分等级打印到不同等级的文件里面。

日志分为五个等级。

#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4

 其中DEBUG、NORMAL、WARNING归类到一个文件里面,剩下的ERROR、FATAL归类到另一个文件里面。

#define LOG_NORMAL "log.txt"
#define LOG_ERR "log.error"

日志代码如下 

log.hpp 

#pragma once

#include <iostream>
#include <stdarg.h>
#include <ctime>
#include <unistd.h>

using namespace std;

#define LOG_NORMAL "log.txt"
#define LOG_ERR "log.error"

#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4

const char *to_levelstr(int level)
{
    switch (level)
    {
    case DEBUG:
        return "DEBUG";
    case NORMAL:
        return "NORMAL";
    case WARNING:
        return "WARNING";
    case ERROR:
        return "ERROR";
    case FATAL:
        return "FATAL";
    default:
        return nullptr;
    }
}

void logMessage(int level, const char *format, ...)
{
    // [日志等级] [时间] [pid] [message]

#define NUM 1024

    time_t now = time(nullptr);      // 获取当前时间
    tm *localTime = localtime(&now); // 将时间转换为结构体
    char timeStr[NUM];
    strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", localTime); // 格式化时间为指定格式
    char logprefix[NUM];
    // 使用格式化后的时间字符串组装日志前缀
    snprintf(logprefix, sizeof(logprefix), "[%s][%s][pid: %d]",
             to_levelstr(level), timeStr, getpid());

    char logcontent[NUM];
    va_list arg;
    va_start(arg, format);
    vsnprintf(logcontent, sizeof(logcontent), format, arg);

    FILE *log = fopen(LOG_NORMAL, "a");
    FILE *err = fopen(LOG_ERR, "a");
    if (log != nullptr && err != nullptr)
    {
        FILE *curr = nullptr;
        if (level == DEBUG || level == NORMAL || level == WARNING)
            curr = log;
        if (level == ERROR || level == FATAL)
            curr = err;
        if (curr)
            fprintf(curr, "%s%s\n", logprefix, logcontent);

        fclose(log);
        fclose(err);
    }
}

 其他代码就不贴了,上传到Gitee了:code: 用于个人代码的保存 - Gitee.com

测试结果,服务器运行,就已经把日志打印到文件里面了。

六、Linux任务管理与守护进程

关闭 shell 之后,我们运行的进程也跟着销毁了。即我们运行的服务器也随之销毁,这显然是不合理了,所以这并不是服务器真正运行的样子。所以,下面要解决的就是这个问题,顺便介绍Linux任务管理与守护进程。

关闭第一个shell,该进程的信息已经查不到了,说明该进程已经销毁了。

  

6.1 任务管理

6.1.1 进程组

每个进程除了有一个进程ID之外,还属于一个进程组,进程组是一个或多个进程的集合。

进程组(Process Group)是一组具有相同进程组ID(PGID)的进程的集合。每个进程组都有一个唯一的PGID,用于标识进程组。

进程组的主要作用是将一组相关的进程组织在一起,以便可以对它们进行集体操作。例如,可以向进程组发送信号,以便同时影响组内的所有进程。进程组还可以用于实现作业控制,其中一个进程组被分配为前台作业,其他进程组被分配为后台作业。 

在 Linux 系统中,进程组的 ID 是由内核分配的,进程组的ID范围为正整数。进程组的ID为0的特殊进程组被称为“无效进程组”,用于标识没有有效进程组的进程。

需要注意的是,只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。

例如,这里的PGID就是进程组

6.1.2 作业概念

在Linux中,作业(Job)是指在终端或终端仿真器中运行的一个或多个命令的集合。

作业分前台作业和后台作业:

  • 前台作业(Foreground Job):在终端中直接运行的命令或程序,默认情况下,前台作业会占用终端的控制权,并且会将输出直接显示在终端上。
  • 后台作业(Background Job):在命令的末尾添加&符号,可以将命令放到后台运行,不会占用终端的控制权,并且会将输出重定向到一个文件或/dev/null

注:一个前台作业可以由多个进程或线程组成,一个后台作业也可以由多个进程或线程组成。

前台任务只能有一个,后台任务可以有多个或者没有 

默认情况下,我们登录 Xshell 后,bash 会默认占据前台任务,也就是命令行解释器 shell(即占用终端的控制权)。

比如,我们随便运行一个不会退出的程序,比如上面的服务端程序。

该进程任务自动切换为前台任务,shell 自动切换为后台任务,我们输入的命令就无效了。

Linux提供了一些作业控制命令来管理和控制作业:

  • jobs:查看当前终端中运行的作业列表。
  • fg(foreground):将后台作业切换到前台运行。
  • bg(background ):将后台作业切换到后台继续运行。
  • kill:终止指定作业的运行。

作业与进程组的区别:

  • 如果作业中的某个进程又创建了子进程,则子进程不属于作业。
  • 一旦作业运行结束,shell 就把自己提到前台,如果原来的前台进程还存在,也就是这个被创建的子进程还没有终止,那么它将自动变为后台进程组

6.1.3 会话概念

在Linux中,会话(Session)是指从用户登录到系统开始,到用户退出系统结束的整个时间段。

也就是说一个用户进行登录Linux,Linux系统就会分配一个会话给我们,直到我们主动退出这个会话。在一个会话中,用户可以与系统进行交互,执行命令、操作文件、启动程序等。

6.1.4 操作

先创建几个进程组,为了方便直接用sleep代替应用程序。

在命令的末尾添加&符号,可以将命令放到后台运行,不会占用终端的控制权,此时命令行依旧生效。

查看一下进程信息,进程组的PGID相同代表的是在同一个进程组。

使用jobs:查看当前终端中运行的作业列表,例如

其中,前面的序号就是任务编号,用于辨别多个任务。

使用fg命令可以将后台作业切换到前台运行,后面带上作业的编号。

由于1号作业被提至前台运行,所以其运行状态也由S变成了S++就是代表是前台任务。

注意:前台进程只能有一个,当一个进程变成前台进程后,bash会自动变为后台进程,此时 bash 就无法进行命令行解释了。 

将一个前台进程放到后台运行可以使用ctrl+z,但使用ctrl+z后该进程就会处于停止状态(Stopped)。

bg:将后台作业切换到后台继续运行。 可以让某个停止的作业在后台继续运行(Running)。

6.2 守护进程

6.2.1 概念

在Linux系统中,守护进程(Daemon Process)是在后台运行的一种特殊类型的进程。它与用户交互的终端分离,通常在系统启动时自动启动,并在系统运行期间持续运行,直到系统关闭或手动停止。守护进程也称精灵进程,本质是孤儿进程的一种。

守护进程通常用于执行一些需要持续运行的任务,比如网络服务、系统监控、定时任务等。与普通进程不同,守护进程没有终端与之关联,也没有用户交互。它们在后台默默地运行,执行系统任务,并通过日志文件记录运行状态和输出信息。

6.2.2 查看

可以用ps axj命令查看系统中的进程:

  • 参数a表示不仅列出当前用户的进程,也列出所有其他用户的进程。
  • 参数x表示不仅列出有控制终端的进程,也列出所有无控制终端的进程。
  • 参数j表示列出与作业控制相关的信息。

TPGID一栏写着-1的都是没有控制终端的进程,也就是守护进程。

6.2.3 创建守护进程

创建守护进程一般不喜欢使用系统提供的,因为有未定义行为,一般都是自己写。

daemon函数是系统提供的。

创建守护进程的过程可以分为以下几个步骤:

  1. 让调用进程忽略掉异常的信号。

  2. 创建子进程:使用fork()系统调用创建一个子进程。

  3. 脱离终端(核心):使用setsid()系统调用使子进程脱离终端,成为一个新的会话组长。

  4. 关闭文件描述符:关闭所有文件描述符,以防止守护进程与终端或其他进程的关联。可以使用close()系统调用来关闭文件描述符。

  5. 重定向标准输入输出、错误:将标准输入、输出和错误重定向到/dev/null或日志文件中。可以使用dup2()系统调用来重定向文件描述符。

  6. 设置工作目录(可选):将工作目录切换到根目录,以防止守护进程运行时影响其他目录。可以使用chdir()系统调用来切换工作目录。

代码如下:

#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <cassert>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define DEV "/dev/null"

void daemonSelf(const char *currPath = nullptr)
{
    // 1. 让调用进程忽略掉异常的信号
    signal(SIGPIPE, SIG_IGN);

    // 2. 创建子进程
    if (fork() > 0)
        exit(0);
    // 子进程 -- 守护进程,精灵进程,本质就是孤儿进程的一种!
    // 3.脱离终端:使用setsid()系统调用使子进程脱离终端,成为一个新的会话组长。
    pid_t n = setsid();
    assert(n != -1);

    // 4. 关闭文件描述符 或 重定向标准输入输出、错误
    int fd = open(DEV, O_RDWR);
    if (fd >= 0)
    {
        dup2(fd, 0);
        dup2(fd, 1);
        dup2(fd, 2);

        close(fd);
    }
    else
    {
        close(0);
        close(1);
        close(2);
    }

    // 5. 可选:进程执行路径发生更改

    if (currPath)
        chdir(currPath);
}

/dev/null

  • /dev/null是Linux操作系统中的一个特殊文件,它会丢弃所有写入它的数据,并在从中读取时返回文件结束条件。
  • 它通常被用作丢弃不需要的输出或测试程序在遇到写入错误时的行为。
  • /dev/null形象称为黑洞,或文件黑洞。

setsid函数

creates a session and sets the process group ID:创建会话并设置进程组ID。

返回值:函数调用成功后,将返回调用进程的(新)会话ID。出现错误时,返回(pid_t)-1,错误码被设置。

如果调用进程不是进程组的组织,setsid 将创建一个新会话。

如何让自己不是进程组组长??

  • 创建子进程:使用fork()系统调用创建一个子进程,让父进程直接退出。

注意:创建子进程成立新会话后,子进程自己就成了进程组,与终端设备无关。

测试

给服务端加上该代码,进行测试

编译运行服务端

查看进程信息

发现该进程的TPGID为-1,代表的是守护进程,TTY显示的是,也就意味着该进程已经与终端去关联了 

PPID为1,说明OS领养了守护进程,守护进程本质是孤儿进程的一种。

现在把自己的终端关掉,重新连接,该进程依旧可以查到,说明进程已经守护进程化了,这就是服务器正确的运行方式。

七、TCP协议通讯流程

TCP协议通讯流程这里只是浅谈,后序再详谈,这里只有简单认识。

TCP协议的客户端/服务器程序的一般流程: 

7.1 三次握手

三次握手就是客户端向服务端发起连接的过程(简单了解,后序详谈)

 服务器初始化

  • 调用 socket 创建文件描述符。
  • 调用 bind,将当前的文件描述符和 ip/port 绑定在一起;如果这个端口已经被其他进程占用了,就会bind失败。
  • 调用 listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的 accept 做好准备。
  • 调用 accept,并阻塞,等待客户端连接过来。

 建立连接的过程

  • 调用 socket,创建文件描述符。
  • 调用 connect,向服务器发起连接请求。
  • connect会发出SYN并阻塞等待服务器应答 (第一次)。
  • 服务器收到客户端的SYN,会应答一个SYN+ACK段表示"同意建立连接"(第二次)。
  • 客户端收到SYN+ACK后会从connect返回,同时应答一个ACK段(第三次) 。

这个建立连接的过程,通常称为三次握手

7.2 数据传输

双方建立好连接之后就可以进行数据传输了

 数据传输的过程

  •  建立连接后,TCP协议提供全双工的通信服务。
  • 服务器从accept返回后立刻调用read,读socket就像读管道一样,如果没有数据到达就阻塞等待。
  • 这时客户端调用write发送请求给服务器,服务器收到后从read返回,对客户端的请求进行处理,在此期间客户端调用read阻塞等待服务器的应答。
  • 服务器调用write将处理结果发回给客户端,再次调用read阻塞等待下一条请求。
  • 客户端收到后从read返回,发送下一条请求,如此循环下去。

注意:用read读取数据是有问题的,你不能保证数据读取完了,或者数据只读取了一部分,又或者数据没有及时读取,这些问题后序再谈,这就是为什么说 TCP是面向字节流。

7.3 四次挥手

如果不想通信了,双方就要断开连接

断开连接的过程 

  • 如果客户端没有更多的请求了,就调用 close 关闭连接, 客户端会向服务器发送FIN段(第一次)。
  • 此时服务器收到FIN后, 会回应一个ACK,同时read会返回0 (第二次)。
  • read 返回之后,服务器就知道客户端关闭了连接,也调用 close 关闭连接,这个时候服务器会向客户端发送一个FIN (第三次)。
  • 客户端收到FIN,再返回一个ACK给服务器 (第四次) 。

这个断开连接的过程,通常称为四次挥手

在学习socket API时要注意应用程序和TCP协议层是如何交互的。

  • 应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect会发出SYN段。
  • 应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的 socket 函数返回就表明TCP协议收到了某些段,再比如read返回0就表明收到了FIN段。

注:以上概念先了解,后序再谈。

7.4 TCP和UDP对比

  • 可靠传输 vs 不可靠传输
  • 有连接 vs 无连接
  • 字节流 vs 数据报

到目前为止,我们通过代码知道TCP是有连接 和UDP是无连接,而可靠和不可靠传输、面向字节流和面向数据报暂时体会不到,后序谈原理的时候就可以理解了。

--------------- END ---------------

「 作者 」 枫叶先生
「 更新 」 2023.6.23
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。