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

一無是處的研究僧發表於2022-12-03

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

在上期文章你在終端啟動的程式,最後都是什麼下場?(上)當中我們介紹了前臺程式最終結束的幾種情況,在本篇文章當中主要給大家介紹後臺程式號可能被殺死的幾種情況。

揭秘nohup——後臺程式的死亡

如果大家有過讓程式在後臺持續的執行,當你退出終端之後想讓你的程式繼續在後臺執行,我們通常會使用命令 nohup。那麼現在問題來了,為什麼我們讓程式在後臺執行需要 nohup 命令,nohup 命令又做了什麼?

在前面的文章你在終端啟動的程式,最後都是什麼下場?(上)當中我們已經談到了,當你退出終端之後 shell 會傳送 SIGHUP 訊號給前臺程式組的所有程式,然後這些程式在收到這個訊號之後如果沒有重寫 SIGHUP 訊號的 handler 或者也沒有忽略這個訊號,那麼就會執行這個訊號的預設行為,也就是退出程式的執行。

事實上當你退出終端之後 shell 不僅給前臺程式組的所有程式傳送 SIGHUP 訊號,而且也會給所有的後臺程式組傳送 SIGHUP 訊號,因此當你退出終端之後你啟動的所有後臺程式都會收到一個 SIGHUP 訊號,注意 shell 是給所有的後臺程式組傳送的訊號,因此如果你的後臺程式是一個多程式的程式的話,那麼你這個多程式程式的每一個程式都會收到這個訊號。

根據上面的分析我們就可以知道了當我們退出終端之後,shell 會給後臺程式傳送一個 SIGHUP 訊號。在我們瞭解了 shell 的行為之後我們應該可以理解為什麼我麼需要 nohup 命令,因為我們正常的程式是沒有處理這個 SIGHUP 訊號的,因此當我們退出終端之後所有的後臺程式都會收到這個訊號,然後終止執行。

看到這裡你應該能夠理解 nohup 命令的原理和作用了,這個命令的作用就是讓程式忽略 SIGHUP 這個訊號,我們可以透過 nohup 的原始碼看出這一點。

nohup 的核心程式碼如下所示:

int
main(int argc, char *argv[])
{
	int exit_status;

	while (getopt(argc, argv, "") != -1)
		usage();
	argc -= optind;
	argv += optind;
	if (argc < 1)
		usage();

	if (isatty(STDOUT_FILENO))
		dofile();
	if (isatty(STDERR_FILENO) && dup2(STDOUT_FILENO, STDERR_FILENO) == -1)
		/* may have just closed stderr */
		err(EXIT_MISC, "%s", argv[0]);

	(void)signal(SIGHUP, SIG_IGN); // 在這裡忽略 SIGHUP 這個訊號

	execvp(*argv, argv); // 執行我們在命令列當中指定的程式
	exit_status = (errno == ENOENT) ? EXIT_NOTFOUND : EXIT_NOEXEC;
	err(exit_status, "%s", argv[0]);
}

在上面的程式當中我們可以看到,在 main 函式當中,nohup 首先建立使用 signal 忽略了 SIGHUP 訊號,SIG_IGN 就是忽略這個訊號,然後使用 execvp 執行我們在命令列當中指定的程式。

這裡需要注意一點的是關於 execvp 函式,也就是 execve 這一類系統呼叫,只有當我們使用 SIG_IGN 忽略訊號的時候,才會在 execvp 系列函式當中起作用,如果是我們自己定義的訊號處理器 (handler),那麼在我們執行完 execvp 這個系統呼叫之後,所有的我們自己定義的訊號處理器的行為都將失效,所有被重新用新的函式定義的訊號都會恢復成訊號的預設行為。

比如說下面這個程式:

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

void sig(int no)
{
  char* s = "Hello World\n";
  write(STDOUT_FILENO, s, strlen(s));
  sync();
}

int main(int argc, char* argv[], char* argvp[])
{

  signal(SIGINT, sig);
  execvp(argv[1], argv);
}

在上面的程式當中我們定義了一個訊號處理器 sig 函式,如果接受到 SIGINT 訊號那麼就會執行 sig 函式,但是我們前面說了,因為只有 SIG_IGN 才能在 execvp 函式執行之後保持,如果是自定函式的話,那麼這個訊號的行為就會被重置成預設行為,SIGINT 的預設行為是退出程式,現在我們使用上面的程式去載入執行一個死迴圈的程式,執行結果如下:

從上面的程式的輸出結果我們就可以知道,在我們按下 ctrl + c 之後程式會收到一個來自核心的 SIGINT 訊號,但是並沒有執行我們設定的函式 sig ,因此驗證了我們在上文當中談到的結論!

有心的同學可能會發現當我們在終端使用 nohup 命令的時候會生成一個 "nohup.out" 檔案,記錄我們的程式的輸出內容,我們可以在 nohup 的原始碼當中發現一點蛛絲馬跡,我們可以看一下 nohup 命令的完整原始碼:

#if 0
#ifndef lint
static const char copyright[] =
"@(#) Copyright (c) 1989, 1993\n\
	The Regents of the University of California.  All rights reserved.\n";
#endif /* not lint */

#ifndef lint
static char sccsid[] = "@(#)nohup.c	8.1 (Berkeley) 6/6/93";
#endif /* not lint */
#endif
#include <sys/cdefs.h>
__FBSDID("FreeBSD");

#include <sys/param.h>
#include <sys/stat.h>

#include <err.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static void dofile(void);
static void usage(void);

#define	FILENAME	"nohup.out" // 定義輸出檔案的檔名
/*
 * POSIX mandates that we exit with:
 * 126 - If the utility was found, but failed to execute.
 * 127 - If any other error occurred. 
 */
#define	EXIT_NOEXEC	126
#define	EXIT_NOTFOUND	127
#define	EXIT_MISC	127

int
main(int argc, char *argv[])
{
	int exit_status;

	while (getopt(argc, argv, "") != -1)
		usage();
	argc -= optind;
	argv += optind;
	if (argc < 1)
		usage();

	if (isatty(STDOUT_FILENO))
		dofile();
	if (isatty(STDERR_FILENO) && dup2(STDOUT_FILENO, STDERR_FILENO) == -1)
		/* may have just closed stderr */
		err(EXIT_MISC, "%s", argv[0]);

	(void)signal(SIGHUP, SIG_IGN);

	execvp(*argv, argv);
	exit_status = (errno == ENOENT) ? EXIT_NOTFOUND : EXIT_NOEXEC;
	err(exit_status, "%s", argv[0]);
}

static void
dofile(void)
{
	int fd;
	char path[MAXPATHLEN];
	const char *p;

	/*
	 * POSIX mandates if the standard output is a terminal, the standard
	 * output is appended to nohup.out in the working directory.  Failing
	 * that, it will be appended to nohup.out in the directory obtained
	 * from the HOME environment variable.  If file creation is required,
	 * the mode_t is set to S_IRUSR | S_IWUSR.
	 */
	p = FILENAME;
  // 在這裡開啟 nohup.out 檔案
	fd = open(p, O_RDWR | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR);
	if (fd != -1)
    // 如果檔案開啟成功直接進行檔案描述符的替代,將標準輸出重定向到檔案 nohup.out 
		goto dupit;
	if ((p = getenv("HOME")) != NULL && *p != '\0' &&
	    (size_t)snprintf(path, sizeof(path), "%s/%s", p, FILENAME) <
	    sizeof(path)) {
		fd = open(p = path, O_RDWR | O_CREAT | O_APPEND,
		    S_IRUSR | S_IWUSR);
		if (fd != -1)
			goto dupit;
	}
	errx(EXIT_MISC, "can't open a nohup.out file");

dupit:
	if (dup2(fd, STDOUT_FILENO) == -1)
		err(EXIT_MISC, NULL);
	(void)fprintf(stderr, "appending output to %s\n", p);
}

static void
usage(void)
{
	(void)fprintf(stderr, "usage: nohup [--] utility [arguments]\n");
	exit(EXIT_MISC);
}

在原始碼當中的宏 FILENAME 定義的檔名就是 nohup.out,在上面的程式碼當中,如果判斷當前程式的標準輸出是一個終端裝置就會開啟檔案 nohup.out 然後將程式的標準輸出重定向到檔案 nohup.out ,因此我們在程式當中使用 printf 的輸出就都會被重定向到檔案 nohup.out 當中,看到這裡就破案了,原來如此。

後臺程式和終端的糾纏

後臺程式是不能夠從終端讀取內容的,當我們從終端當中讀的時候核心就會給這個後臺程式傳送一個 SIGTTIN 訊號,這個條件主要是避免多個不同的程式都讀終端。如果後臺程式從終端當中進行讀,那麼這個程式就會收到一個 SIGTTIN 訊號,這個訊號的預設行為就是退出程式。

我們可以使用下面的程式程式測試:

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


void sig(int no, siginfo_t* si, void* ucontext)
{
  char s[1024];
  sprintf(s, "signal number = %d sending pid = %d\n", no, si->si_pid);
  write(STDOUT_FILENO, s, strlen(s));
  sync();
  _exit(0);
}

int main()
{
  struct sigaction action;
  action.sa_flags |= SA_SIGINFO;
  action.sa_sigaction = sig;
  action.sa_flags &= ~(SA_RESETHAND);
  sigaction(SIGTTIN, &action, NULL);
  while(1)
  {
    char c = getchar();
  }
  return 0;
}

然後我們在終端輸入命令,並且對應的輸出如下:

➜  daemon git:(master) ✗ ./job11.out&
[1] 47688
signal number = 21 sending pid = 0                                                                                
[1]  + 47688 done       ./job11.out

從上面程式的輸出結果我們可以知道,當我們在程式當中使用函式 getchar 讀入字元的時候,程式就會收到來自核心的訊號 SIGTTIN,根據下面的訊號名和編號表可以知道,核心傳送的訊號位 SIGTTIN。

當我們在終端當中進行寫操作的時候會收到訊號 SIGTTOU,但是預設後臺程式是可以往終端當中寫的,如果我們想要程式不能夠往終端當中寫,當程式往終端當中寫資料的時候就收到訊號 SIGTTOU,我們可以使用命令 stty 進行設定。我們使用一個例子看看具體的情況:

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


void sig(int no, siginfo_t* si, void* ucontext)
{
  char s[1024];
  sprintf(s, "signal number = %d sending pid = %d\n", no, si->si_pid);
  write(STDOUT_FILENO, s, strlen(s));
  sync();
  _exit(0);
}

int main()
{
  struct sigaction action;
  action.sa_flags |= SA_SIGINFO;
  action.sa_sigaction = sig;
  action.sa_flags &= ~(SA_RESETHAND);
  sigaction(SIGTTOU, &action, NULL);
  while(1)
  {
    sleep(1);
    printf("c");
    fflush(stdout);
  }
  return 0;
}

上面是一個比較簡單的訊號程式,不斷的往終端當中輸出字元 c,我們可以看一下程式的執行情況(job12 就是上面的程式碼):

➜  daemon git:(master) ✗ stty tostop 
➜  daemon git:(master) ✗ ./job12.out&
[1] 48467
➜  daemon git:(master) ✗ signal number = 22 sending pid = 0

[1]  + 48467 done       ./job12.out

在上面的輸出結果當中我們使用命令 stty tostop 主要是用於啟動當有後臺程式往終端當中寫內容的時候,向這個程式傳送 SIGTTOU 訊號,這個訊號的預設行為也是終止程式的執行。

首先看一下當我們沒有使用 stty tostop 命令的時候程式的行為。

現在我們使用 stty tostop 命令重新設定一下終端的屬性,然後重新程式測試:

從上面的輸出結果我們可以看到當我們在終端當中,預設是允許程式往終端當中進行輸出的,但是當我們使用命令 stty tostop 之後,如果還有後臺程式往終端當中進行輸出,那麼這個程式就會收到一個 SIGTTOU 訊號。

後臺程式和終端的命令互動

在前文當中我們談到了當我們在一條命令後面加上 & 的話,那麼這個程式將會變成後臺程式。那麼有沒有辦法將一個後臺程式變成前臺程式呢?

當然有辦法,我們可以使用 fg ——一個 shell 的內建命令,將一個後臺程式變成前臺程式。在正式進行驗證之前我們需要來了解三個命令:

  • jobs 這條命令主要是用於檢視當前所有的後臺程式組,也就是所有的後臺作業,。
  • fg 這條命令主要是將一個後臺程式放到前臺來執行。
  • bg 這條命令主要是讓一個終端的後臺程式繼續執行。

具體的例子如下所示:

➜  daemon git:(master) ✗ sleep 110 & # 建立一個後臺程式 每當建立一個後臺作業 shell 都會給這個作業分配一個作業號 就是 [] 當中的數字,從 1 開始
[1] 7467
➜  daemon git:(master) ✗ sleep 111 & # 建立一個後臺程式
[2] 7485
➜  daemon git:(master) ✗ sleep 112 & # 建立一個後臺即成
[3] 7503
➜  daemon git:(master) ✗ jobs # 檢視所有的後臺程式 其中 + 表示當前作業 可以認為是最近一次使用 & 生成的作業 - 表示上一個作業 可以認為是倒數第二個使用 & 生成的作業
[1]    running    sleep 110
[2]  - running    sleep 111
[3]  + running    sleep 112
➜  daemon git:(master) ✗ fg # fg 的使用方式為 fg %num 如果不指定 %num 的話,預設就是將當前作業放到前臺 餓我們在上面已經談到了 當前作業為 sleep 112 因此將這個程式恢復到前臺
[3]  - 7503 running    sleep 112
^C # 終止這個作業
➜  daemon git:(master) ✗ jobs  # 因為終止了作業 sleep 112 因此後臺程式組只剩下兩個了
[1]    running    sleep 110
[2]  + running    sleep 111
➜  daemon git:(master) ✗ fg  # 在將最近一次提交的作業放到前臺
[2]  - 7485 running    sleep 111
^C # 終止這個任務的執行
➜  daemon git:(master) ✗ sleep 112 &
[2] 7760
➜  daemon git:(master) ✗ jobs 
[1]  - running    sleep 110
[2]  + running    sleep 112
➜  daemon git:(master) ✗ sleep 112 &
[3] 7870
➜  daemon git:(master) ✗ sleep 112 &
[4] 7888
➜  daemon git:(master) ✗ jobs 
[1]    running    sleep 110
[2]    running    sleep 112
[3]  - running    sleep 112
[4]  + running    sleep 112
➜  daemon git:(master) ✗ fg %1 
[1]    7467 running    sleep 110
^C
➜  daemon git:(master) ✗ 

接下來我們使用下面的程式進行驗證,下面的程式的主要目的就是判斷當前程式是否是前臺程式,如果是則列印訊息,如果不是那麼就一直進行死迴圈:

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

int main()
{
  while(1)
  {
    sleep(1);
    // tcgetpgrp 返回前臺程式組的程式組號
    // getpgid(0) 得到當前程式組的程式組號
    // 如果兩個結果相等則說明當前程式組是前臺程式組
    // 反之則是後臺程式組
    if(getpgid(0) == tcgetpgrp(STDOUT_FILENO))
    {
      printf("I am a process of foregroup process\n");
    }
  }
  return 0;
}

然後我們在終端當中執行這個程式,對應的幾個結果如下所示:

➜  daemon git:(master) ✗ ./job13.out& # 先將這個程式放到後臺執行,因為不是前臺程式因此不會列印訊息
[1] 5832
➜  daemon git:(master) ✗ fg    # 將這個程式放到前臺執行,因為到了前臺因此上面的程式會輸出訊息
[1]  + 5832 running    ./job13.out
I am a process of foregroup process
I am a process of foregroup process
I am a process of foregroup process
^Z
[1]  + 5832 suspended  ./job13.out # 在這裡我們按下 ctrl + z 給程式傳送 SIGTSTP 訊號 讓程式暫停執行
➜  daemon git:(master) ✗ bg %1 # bg 命令預設是給程式傳送一個 SIGCONT 因為在上一行當中訊號 SIGTSTP 讓程式暫停執行了 因此程式在收到訊號 SIGCONT 之後會繼續執行(SIGCONT 的作用就是讓一個暫停的程式繼續執行)
[1]  + 5832 continued  ./job13.out # 因為程式還是在後臺當中,因此程式繼續執行還是在後臺執行,所以依然沒有輸出
➜  daemon git:(master) ✗ fg %1 # 這條命令是讓後臺程式組當中的第一個作業到前臺執行,因此程式開始列印輸出
[1]  + 5832 running    ./job13.out
I am a process of foregroup process
I am a process of foregroup process
^C # 在這裡輸入 ctrl + c 命令,讓前臺程式組當中所有程式停止執行
➜  daemon git:(master) ✗ 

在上面的輸出結果當中,我們首先在後臺啟動一個程式,因為是在後臺所以當前程式組不是前臺程式組,因此不會在終端當中列印輸出,而當我們使用 fg 命令將後臺當中的最近生成的一個作業(當我們輸入命令之後,終端列印的[]當中的數字就是表示作業號,預設是從 1 開始的,因為我們只啟動一個後臺程式(執行一條命令就是開啟一個作業),因此作業號等於 1)放到前臺來執行,在上面的例子當中,命令 fg 和 fg %1 的效果是一樣的。

總結

在本篇文章當中主要給大家介紹了後臺程式的一些生與死的情況,總體來說有以下內容:

  • 當我們退出終端的時候,shell 會給所有前臺和後臺程式組傳送一個 SIGHUP 訊號,nohup 命令的原理就是讓程式忽略這個 SIGHUP 訊號。
  • 當後臺程式從終端當中讀的時候核心會給這個程式傳送一個 SIGTTIN 訊號。
  • 當我們設定了 ssty tostop 之後,如果我們往終端當中進行寫操作的話,那麼核心會給這個程式傳送一個 SIGTTOU 訊號,這兩個訊號的預設行為都是終止這個程式的執行。
  • 我們可以使用 jobs fg bg 命令讓終端和後臺程式進行互動操作,fg 將一個後臺程式放到前臺執行,如果這個進行暫停執行的話, shell 還會給這個程式傳送一個 SIGCONT 訊號讓這個程式繼續執行,bg 可以讓一個後臺暫停執行的程式恢復執行,本質也是給這個後臺程式傳送一個 SIGCONT 訊號。
  • 當你在終端當中輸入 ctrl + c 的時候,核心會給所有的前臺程式組當中所有的程式傳送 SIGINT 訊號,當你在終端輸入 ctrl + z 時,核心會給前臺程式組當中的所有程式傳送 SIGTSTP 訊號,當你在終端輸入 ctrl + \ 核心會給所有的前臺程式組傳送 SIGQUIT 訊號。
  • 綜合上面的分析,上面的結果可以使用下面的圖進行表示分析。

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

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

相關文章