天天看點

[apue] 書中關于僞終端的一個纰漏

名著挑刺系列,不是硬傷,個人感覺隻是表述的時候不太貼切,可能導緻誤解

在看 apue 第 19 章僞終端第 6 節使用 pty 程式時,發現“檢查長時間運作程式的輸出”這一部分内容的實際運作結果,與書上所說有出入。

于是展開一番研究,最終發現是書上講的有問題,現在摘出來讓大家評評理。

先上代碼

pty.c

pty_fun.c

這是書上标準的 pty 程式,簡單說起來就是提供一個僞終端給被調用程式使用,例如

pty prog arg1 arg2
      

相當于在新的僞終端上執行

prog arg1 arg2
      

進而可以避免一些直接執行 prog 帶來的問題。

19.6 節重點介紹使用 pty 程式的 6 種場景,其中第 3 種是檢查長時間運作程式的輸出,

假設我們有一個程式 slowout,它要執行很長時間,而輸出又稀稀拉拉,通過

slowout > out.log & 
      

執行,同時

tail -f out.log 
      

檢視的話,因為輸出到檔案會被緩存,導緻不能及時看到 slowout 的輸出,甚至隻有等 slowout 退出後,才能看到一點兒輸出。

為了解決這個問題,引入 pty 程式

pty slowout > out.log &
      

此時通過 tail 指令檢視日志檔案就會比較及時,這是因為 pty 提供的僞終端是行緩存的,slowout 輸出一行就會被寫入檔案。

事情這樣就完美了?非也,作者提出了一個場景,當 slowout 有可能讀取 stdin 的時候,因為它本身在背景執行,

一旦妄圖讀取終端上的輸入,就會被系統自動挂起(SIGHUP),進而停止運作,這是作者不想看到的,于是他提出了一種解決方案,

即将标準輸入重定向到 /dev/null,同時開啟 pty 的 -i 選項:

pty -i slowout < /dev/null > out.log &
      

認為這樣可以一勞永逸的解決問題。

先來看一下 pty 程式的運作态結構,再來看 -i 選項的作用,最後我們分析一下為什麼這樣做行不通。

[apue] 書中關于僞終端的一個纰漏

運作時的 pty 首先通過 fork+exec 産生 slowout 子程序,其中标準輸入、輸出分别重定向到中間的僞終端從裝置(pty slave device),

然後它自身又通過 fork 一分為二,pty 父程序負責讀取标準輸入,将内容導入到僞終端主裝置(pty main device),也就是 slowout 的輸入;

pty 子程序負責從僞終端主裝置(pty main device) 讀取資料,也就是 slowout 的輸出,并将内容導出到标準輸出。

那麼 pty 父子程序怎麼退出呢? 當 slowout 結束時,子程序讀僞終端主裝置時傳回 0,它知道工作程序結束後,也即将結束自己的工作,

但是父程序一直卡在讀終端輸入上,并不知道工作程序已經退出,于是 pty 子程序向父程序發送一個 SIGTERM 信号,由父程序捕獲該信号後安全退出。

同理,當 pty 父程序檢查到 stdin 上無更多輸入後,會向 pty 子程序發送 SIGTERM 信号(前提是子程序未發送相同信号),進而終結子程序的等待 。

作者認為問題出現在 pty 父程序向 pty 子程序發送的這個 SIGTERM 信号上,因為重定向到 /dev/null 後,pty 父程序會從 stdin 讀到 EOF,

進而向 pty 子程序發送 SIGTERM,導緻子程序沒有繼續讀 slowout 的輸出就結束了。是以他為 pty 程式加了一個 -i 選項,如果該選項生效,

就在父程序讀 stdin 失敗後,不再向子程序發送 SIGTERM 信号,進而允許 pty 子程序讀 slowout 的輸出直到 slowout 結束。

這個想法很豐滿,但是現實很骨感。

我測試的結果是,如果  slowout 不從标準輸入讀取的話,則一切正常;

而一旦有任何讀取動作,都會導緻  slowout 卡死,進而 pty 子程序卡死,這兩個程序都沒有機會退出。

slowout.c

1 #include <stdio.h>
 2 #include <unistd.h>
 3 
 4 int main (void)
 5 {
 6     int i = 0; 
 7     while (i++ < 10)
 8     {
 9         printf ("turn %d\n", i); 
10         sleep (1); 
11         printf ("type any char to continue\n"); 
12 #ifdef HAS_READ
13         getchar (); 
14 #endif
15     }
16     return 0; 
17 }      

未打開 HAS_READ 開關時,輸出正常:

>./pty -i ./slowout < /dev/null > out.log & 
[1] 7616
>cat out.log
turn 1
type any char to continue
turn 2
type any char to continue
turn 3
type any char to continue
turn 4
type any char to continue
turn 5
type any char to continue
turn 6
type any char to continue
turn 7
type any char to continue
turn 8
type any char to continue
turn 9
type any char to continue
turn 10
type any char to continue
[1]+  Done                    ./pty -i ./slowout < /dev/null > out.log
>
      

打開 HAS_READ 開關後,發現程序卡死:

PID  PPID  PGID   SID TPGID  SUID  EUID USER     STAT TT       COMMAND
 7650     1  7648 10887  7651   500   500 yunhai   S    pts/1    ./pty -i ./slowout
 7649     1  7649  7649  7649   500   500 yunhai   Ss+  pts/3    ./slowout
      

可以通過 ps 指令觀察到卡死的程序,7650 為 pty 子程序,7649 為 slowout 子程序,7648 為 pty 父程序已退出。

通過 pstack 指令可以觀察到 slowout 程序堵塞在 getchar 上:

>pstack 7649
#0  0x009c6424 in __kernel_vsyscall ()
#1  0x00751c53 in __read_nocancel () from /lib/libc.so.6
#2  0x006eb41b in _IO_new_file_underflow () from /lib/libc.so.6
#3  0x006ed13b in _IO_default_uflow_internal () from /lib/libc.so.6
#4  0x006ee74a in __uflow () from /lib/libc.so.6
#5  0x006e7d7c in getchar () from /lib/libc.so.6
#6  0x080485a1 in main ()
      

檢視輸出,果然卡死在第一次 getchar 上:

>cat out.log
turn 1
type any char to continue
      

為什麼會這樣呢? 我們首先要清楚,重定向到 /dev/null 指的是 pty 父程序,并不是 slowout,因為 slowout 重定向到僞終端是固定的,不随外面的重定向操作而改變;同理,輸出重定向到 out.log 指的是 pty 子程序,也不是 slowout。其實所有的重定向操作在 pty 程式運作起來時就已經完成了,根本無法傳遞到 slowout 的參數上(即使傳遞到了也不生效,因為沒有 shell 做解析)。

我們可以通過在 slowout 中加入以下代碼來驗證上面的說法:

1     int tty = isatty (STDIN_FILENO); 
2     printf ("stdin isatty ? %s\n", tty ? "true" : "false"); 
3     tty = isatty (STDOUT_FILENO); 
4     printf ("stdout isatty ? %s\n", tty ? "true" : "false");       

重新編譯後輸出如下:

stdin isatty ? true
stdout isatty ? true
      

 如果是重定向到 /dev/null 或檔案後,isatty 絕對不可能傳回 true,是以可以确定之前的說法是沒問題的。

這樣一來,當 slowout 嘗試讀取時,将從僞終端從裝置讀取,而這個并不會傳回 eof,而是期待 pty 父程序将終端輸入導向這裡。但是 pty 父程序早就因為讀取 /dev/null 得到 EOF 而退出了,隻不過臨退出前因為指定了 -i 參數,沒有将 pty 子程序一并結束罷了。

是以這樣就形成了堵塞的局面,而且這個應該是無解的。

其實 slowout 也可以通過 shell 腳本來實作,正如我一開始做的那樣。

slowout.sh

1 #! /bin/sh
2 for ((i=0; i<10; i=i+1)) {
3     echo "turn $i"
4     ping www.glodon.com -c 4
5     #sleep 4
6     resp=$(read -p "type any char to continue")
7 }      

如果使用 slowout.sh 作為工作程序,啟動指令也需要改變一下:

>./pty -i bash -c ./slowout.sh > out.log < /dev/null &
      

結果是一樣的 (我一開始還以為是 bash 從中進行了影響)。

最終的結論就是:pty 程式并不适用于 slowout 有讀取的情況。

pty