你在終端啟動的程式,最後都是什麼下場?(上)

一無是處的研究僧發表於2022-11-30

你在終端啟動的程式,最後都是什麼下場?(上)

前言

在本篇文章當中,主要給大家介紹我們在終端啟動的程式都是怎麼結束的,在我們登入終端和退出終端都發生了什麼?

基本介紹

首先我們需要了解的概念就是當我們使用 ssh 登入伺服器的時候就會產生一個會話。當我們在終端啟動一個程式之後會建立一個新的程式組,程式組的首程式為要執行的程式,這個程式組可以是一個程式也可以是多個程式,整個程式組在 shell 看來也是一個作業(Job)。如果你不使用 & 符號去執行一個程式,那麼 shell 就會執行這個程式,然後這個程式就成為一個前臺程式組(可以是一個程式也可以是多個程式),當我們在終端當中執行的命令加上 & 符號,那麼這個作業(Job)就會成為一個後臺程式組。

會話、前臺程式組和後臺程式組他們之間的關係大致如下圖所示:

從上面的圖當中我們可以看出,一個會話當中有很多個程式組,分為前臺程式組和後臺程式組,但是後臺程式組可以有多個,前臺呢程式組最多隻能有一個,而且每一個程式組當中可以有多個程式(當你在執行的程式當中 fork 出子程式的時候,就是一個含有多個程式的程式組)。

現在我們使用一個例子具體去了解一下:

#include <stdio.h>
#include <unistd.h>

int main()
{
  // 列印程式的 id 號
  printf("process id = %d\n", getpid());
  // 列印程式的程式組號 0 表示返回當前程式的程式組號
  printf("process group id = %d\n", getpgid(0));
  // 列印程式的父程式號
  printf("parent process id = %d\n", getppid());
  // 列印父程式的程式組號
  printf("parent process group id = %d\n", getpgid(getppid()));
  return 0;
}

上面的程式的輸出結果如下所示:

➜  daemon git:(master) ✗ ./job1.out 
process id = 3773445
process group id = 3773445
parent process id = 3766993
parent process group id = 3766993

從上面程式的輸出結果我們可以看到程式的程式組號和父程式的程式組號是不一樣的,我們需要了解到的是,job1 的父程式就是 shell ,但是他與 shell 的程式組是不一樣的,shell 在執行新的程式的時候會建立一個子程式,然後修改子程式的程式組號,而且新的程式組的組號為子程式的程式號。

或者我們直接在終端輸入命令也可以發現 shell 的子程式的程式組號和 shell 的程式組號是不一樣的:

➜  daemon git:(master) ✗ ps -o pid,ppid,pgid,tty,cmd
    PID    PPID    PGID TT       CMD
3766993 3757891 3766993 pts/1    /usr/bin/zsh -i
3772829 3766993 3772829 pts/1    ps -o pid,ppid,pgid,tty,cmd

在上面的輸出當中,PID,PPID,PGID 分別表示程式的程式號,父程式號和程式組號,CMD 表示執行程式時候的命令。首先我們知道的是 ps 命令程式是 shell 程式的子程式(上面的程式號等於 3766993 的程式就是 shell 程式),從上面的輸出結果也可以得知這一點( ps 的 PPID 就是 shell 的 PID)。

透過上面兩個例子我們可以知道,確實當我們執行程式的時候 shell 會建立一個新的程式組,事實上只要是在終端裡面執行的程式,都會建立一個新的程式組。如果你熟悉 linux 的話,那麼肯定用過 & 符號,這個符號就是將任務放在後臺執行,這樣建立的程式組就是後臺程式組。

前臺程式的死亡情況列表

前臺程式組的死亡一般來說有四種情況:

  • 程式正常結束,比如你在終端輸入 ls 命令,執行完成✅之後他就正常結束了。
  • 當你在終端輸入 ctrl + c 之類的字元的時候核心會給前臺程式組傳送 SIGINT 之類的訊號。
  • 當控制程式 也就是 shell 程式終止(比如說被殺掉了)的時候,核心會傳送 SIGHUP 訊號給前臺程式組中的所有程式。
  • 當退出終端的時候,shell 會傳送 SIGHUP 訊號給前臺程式組。

初探訊號

大家如果經常使用 linux 的話,一定會有過這種情況:當你在終端執行一個程式的時候,你突然遇到某些問題不想執行他了,然後你會瘋狂按 ctrl + c ,讓這個程式退出。那當你在終端按下 ctrl + c 的時候程式一定會停止嘛?如果程式退出了,那是什麼原因導致他退出的呢?事實上,當你在終端按下 ctrl + c 的時候,核心會想前臺程式組所有的程式傳送一個 SIGINT 訊號,注意這裡是前臺程式組中的所有程式,但是通常我們在終端裡執行的就是一個單程式任務,但是如果我們執行的程式是多程式的話,那麼這個程式組裡面的所有程式都會收到一個來自作業系統核心的 SIGINT 訊號。

為了後面我們進行驗證的時候大家能夠了解清楚程式的行為,我們首先介紹一下訊號處理函式的使用,所謂訊號處理函式就是,當程式收到一個由其他程式或者作業系統核心傳送的訊號的時候,我們可以定義一個函式去處理訊號,也就是定義收到訊號的行為:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

void sig(int signo) // signo 這個引數就是對應訊號的數字表示 SIGINT 訊號對應的數字為 2
{
  char* s = "received a signal\n";
  write(STDOUT_FILENO, s, strlen(s));
}

int main()
{
  // 註冊收到 SIGINT 訊號的時候,我們應該使用什麼處理函式
  // 當程式收到 SIGINT 訊號的時候,會呼叫函式 sig 
  signal(SIGINT, sig);
  while(1);
  return 0;
}

上面的程式的輸出結果如下所示:

➜  daemon git:(master) ✗ ./job4.out 
^Creceived a signal
^Creceived a signal
^Creceived a signal
^Creceived a signal
^Creceived a signal
^Creceived a signal
^Creceived a signal

從上面的終端的輸出結果我們可以知道,當我們在終端輸入 SIGINT 的時候,程式會收到一個 SIGINT 訊號,然後會呼叫訊號處理函式 sig ,並且執行函式體。

現在我們執行一個多程式的任務試試:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>

void sig(int signo) // signo 這個引數就是對應訊號的數字表示 SIGINT 訊號對應的數字為 2
{
  char s[1024];
  sprintf(s, "received a signal %d\n", getpid()); // 輸出內容並且答應程式的程式號
  write(STDOUT_FILENO, s, strlen(s));
}

int main()
{
  // 註冊收到 SIGINT 訊號的時候,我們應該使用什麼處理函式
  // 當程式收到 SIGINT 訊號的時候,會呼叫函式 sig 
  signal(SIGINT, sig);
  fork();
  fork();
  while(1);
  return 0;
}

在上面的程式當中,我們 fork 的兩次,一共有四個程式,上面的程式輸出的結果如下所示:

➜  daemon git:(master) ✗ ./job5.out 
^Creceived a signal 3702
received a signal 3703
received a signal 3705
received a signal 3704
^Creceived a signal 3703
received a signal 3702
received a signal 3705
received a signal 3704
^Creceived a signal 3703
received a signal 3704
received a signal 3705
received a signal 3702

從上面的輸出結果我們可以看到,前臺程式組的所有程式都收到了 SIGINT 訊號。

事實上,我們有如下規則:

在任一時刻,會話(當我們登入伺服器的時候就會產生一個會話,我們使用一些遠端登入軟體的時候通常會看到 session 的字樣們就是表示會話)中的其中一個程式組會成為終端的前臺程式組,其他程式組會成為後臺程式組。只有前臺程式組中的程式才能從控制終端中讀取輸入。當使用者在控制終端中輸入其中一個訊號生成終端字元之後,該訊號會被髮送到前臺程式組中的所有成員。這些字元包括生成 SIGINT 的中斷字元(通常是 Control-C)、生成 SIGQUIT 的退出字元(通常是 Control-\)、生成 SIGSTP 的掛起字元(通常是 Control-Z)。

是誰給前臺程式組傳送的 SIGINT 訊號

在上面的內容當中我們提到了:

  • 當我們按下中斷字元(ctrl + c)的時候,所有的前臺程式都會收到一個 SIGINT 訊號。
  • 當我們按下退出字元(ctrl + \)的時候,所有的前臺程式都會收到一個 SIGQUIT 訊號。
  • 當我們按下掛起字元(ctrl + Z)的時候,所有的前臺程式都會收到一個 SIGTSTP 訊號。

現在的問題來了,是誰傳送的這些訊號呢?事實上是 0 號程式傳送的這些訊號,我們可以使用程式去驗證這一點。

#define _GNU_SOURCE
#include <unistd.h>
#include <error.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>

void my_handler (int signo, siginfo_t *si, void*ucontext)
{
  char s[1024];
  // si->si_pid 是傳送訊號的程式的程式號
  sprintf(s, "傳送訊號的程式號 = %d 訊號 = %d\n", si->si_pid, signo);
  write(STDOUT_FILENO, s, strlen(s));
  _exit(0);
}

int main()
{
  printf("pid = %d\n", getpid());
  struct sigaction demo;
  demo.sa_sigaction = my_handler; // 儲存訊號處理函式
  demo.sa_flags |= SA_SIGINFO; // 這個表示使用三個引數的訊號處理函式 之前使用 signal 的訊號處理函式值有一個引數
  sigaction(SIGINT, &demo, NULL);
  sigaction(SIGQUIT, &demo, NULL);
  sigaction(SIGTSTP, &demo, NULL);
  while(1);
  return 0;
}

上面的程式的輸出結果如下所示:

➜  daemon git:(master) ✗ ./job2.out 
pid = 12842
^C傳送訊號的程式號 = 0 訊號 = 2

在上面的程式當中我們使用 sigaction 去定義我們自己的訊號處理函式,在之前我們是使用 signal 這個函式去定義訊號處理函式,其實 signal 也是透過 sigaction 實現的,sigaction 可以讓我們定義一些更加細節的處理。

從上面的函式定義來看,sigaction 和 signal 不一樣的地方在於訊號處理函式有三個引數,然後我們定義了三個訊號 SIGINT、SIGQUIT和SIGTSTP,他們的訊號處理函式都是 my_handler 。

從上面程式的輸出結果我們可以知道是程式號等於 0 的程式傳送的,哎我們知道的 init 的程式號是等於 1 ,那麼程式號等於 0 的程式是啥呢?

事實上這個 0 號程式就是位於核心的終端驅動程式,init 的程式號是 1 ,核心的程式號等於 0 ,是不是可以理解呢??

Shell 被殺掉導致前臺程式死亡

當 shell 程式被殺掉退出的時候,核心會傳送 SIGHUP 給所有的前臺程式,接下來我們復現一下這個結果。

在前面的文章當中我們提到了,當一個終端終止執行,比如說被 kill -9 殺死,那麼核心就會傳送 SIGHUP 訊號給前臺程式組當中的所有的程式,現在我們使用下面的程式來複現這個現象:

#define _GNU_SOURCE
#include <unistd.h>
#include <error.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <assert.h>

void my_handler (int signo, siginfo_t *si, void*ucontext)
{
  char s[1024];
  int fd = open("text.txt", O_APPEND | O_RDWR | O_CREAT, 0644);
  if(fd == -1)
  {
    perror("");
    abort();
  }
  sprintf(s, "傳送訊號的程式號 = %d 訊號 = %d\n", si->si_pid, signo);
  write(fd, s, strlen(s));
  close(fd);
  fsync(fd);
  _exit(0);
}

int main()
{
  printf("pid = %d\n", getpid());
  struct sigaction demo;
  demo.sa_sigaction = my_handler;
  demo.sa_flags |= SA_SIGINFO;
  demo.sa_flags &= ~SA_RESETHAND;
  sigaction(SIGINT, &demo, NULL);
  sigaction(SIGHUP, &demo, NULL);
  while(1);
  return 0;
}

在上面的程式當中我們給 SIGHUP 定義了一個訊號處理器 my_handler ,當程式收到 SIGHUP 訊號的時候就會呼叫這個函式,然後往 text.txt 檔案當中寫入資料,我們再次檢視檔案就能夠知道是哪個程式給前臺程式組傳送的訊號了。

在下面的圖片當中,首先我們啟動一個 shell 程式,程式號等於 2842,然後啟動程式 job6.out(就是上面的程式碼),然後在右側的終端執行 kill -9 命令,殺死左側的終端程式,最終 job6.out 會收到一個來自核心的 SIGHUP 訊號,因此會在 text.txt 檔案當中寫入資訊。

我們現在再次檢視 text.txt 檔案當中的資訊:

從上面的 text.txt 檔案的輸出結果我們就可以知道了,確實是核心傳送的 SIGHUP 訊號,SIGHUP 訊號對應的訊號數值就是 1,這個輸出結果符合我們的預期。

退出終端導致前臺程式組死亡

我們已經在前文當中提到了,當我們退出終端的時候 shell 會給所有的前臺程式組傳送 SIGHUP 訊號,現在我們來複現一下這個現象。

在下面的圖片當中我們使用 ps 命令得到當前 shell 的程式號,從圖片的結果來看當前的 shell 的程式號等於 3892,然後我們在終端執行程式 job6.out ,和上個例子的是同一個程式,然後我們退出終端,根據前面我們所談到的在退出終端之後 shell 會給所有的前臺程式組傳送 SIGHUP 訊號,那麼 job6.out 就會呼叫訊號處理函式,然後將資訊寫入 text.txt 。

我們現在來看一下 text.txt 當中的內容:

從上面的輸出結果來看 job6.out 確實收到了一個 SIGHUP 訊號,對應的訊號值等於 1,而且傳送訊號的程式號等於 3892,確實是上圖當中顯示的 shell 的程式號。

總結

在本篇文章當中主要給大家介紹了前臺程式組當中的程式退出的幾種情況,並且使用程式復現了他們,主要有以下四種情況:

  • 程式正常結束,比如你在終端輸入 ls 命令,執行完成之後他就正常結束了。
  • 當你在終端輸入 ctrl + c 之類的字元的時候核心會給前臺程式組傳送 SIGINT 之類的訊號。
  • 當控制程式 也就是 shell 程式終止(比如說被殺掉了)的時候,核心會傳送 SIGHUP 訊號給前臺程式組中的所有程式。
  • 當退出終端的時候,shell 會傳送 SIGHUP 訊號給前臺程式組。

總的來說深入去了解其中的過程對我們來說還是很有裨益的,希望大家有所收穫!


以上就是本篇文章的所有內容了,我是LeHung,我們下期再見!!!更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章