Linux進程間通信原理筆記

一、進程間通信

1. 在講進程間通信之前,先來講講管道模式。記得最初學Linux命令的時候,有下面這樣一行命令:

ps -ef | grep 關鍵字 | awk '{print $2}' | xargs kill -9

這裏面的豎線“|”就是一個管道。它會將前一個命令的輸出,作爲後一個命令的輸入。從管道的這個名稱可以看出,管道是一種單向傳輸數據的機制,它其實是一段緩存,裏面的數據只能從一端寫入,從另一端讀出,如果想互相通信,需要創建兩個管道才行。管道分爲兩種類型,“|” 表示的管道稱爲匿名管道,意思就是這個類型的管道沒有名字,用完了就銷燬了。另外一種類型是命名管道,這個類型的管道需要通過mkfifo命令顯式地創建,如下所示:

mkfifo hello

hello就是這個管道的名稱。管道以文件的形式存在,這也符合Linux裏一切皆文件的原則。這個時候ls一下,可以看到這個文件的類型是p,就是pipe的意思,如下所示:

# ls -l
prw-r--r--  1 root root         0 May 21 23:29 hello

接下來可以往管道里面寫入東西,例如寫入一個字符串,如下所示:

# echo "hello world" > hello

這個時候管道里面的內容沒有被讀出,這個命令就是停在這裏的,這說明當一個進程要把它的輸出交接給另一個進程做輸入,當沒有交接完畢的時候,前一個進程是不能撒手不管的。這個時候就需要重新連接一個terminal。在終端中,用下面的命令讀取管道里面的內容:

# cat < hello 
hello world

一方面能夠看到,管道里面的內容被讀取出來,打印到了終端上;另一方面,前一個命令行終端中的echo命令正常退出了,也即交接完畢。但可以看到,管道的使用模式不適合進程間頻繁的交換數據,進程間頻繁溝通的效率較爲低下,就像所謂軟件開發中的瀑布模型。

2. 因此,可以用消息隊列的方式來達到頻繁溝通的目的,就像郵件那樣。和管道將信息一股腦地從一個進程倒給另一個進程不同,消息隊列發送數據時,會分成一個一個獨立的數據單元也就是消息體,每個消息體都是固定大小的存儲塊,在字節流上不連續。這個消息結構的定義如下所示,這裏面的類型type和正文text沒有強制規定,只要消息的發送方和接收方約定好即可:

struct msg_buffer {
    long mtype;
    char mtext[1024];
};

接下來需要創建一個消息隊列,使用msgget函數。這個函數需要有一個參數key,這是消息隊列的唯一標識。如何保持唯一性呢?這個還是和文件關聯,可以指定一個文件,ftok會根據這個文件的inode,生成一個近乎唯一的key,只要在這個消息隊列的生命週期內,這個文件不要被刪除就可以了。只要不刪除,無論什麼時刻再調用ftok,也會得到同樣的key。這種key的使用方式在這裏會經常遇到,因爲它們都屬於System V IPC進程間通信機制體系中。創建消息隊列的實現如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>


int main() {
  int messagequeueid;
  key_t key;


  if((key = ftok("/root/messagequeue/messagequeuekey", 1024)) < 0)
  {
      perror("ftok error");
      exit(1);
  }


  printf("Message Queue key: %d.\n", key);


  if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)
  {
      perror("msgget error");
      exit(1);
  }


  printf("Message queue id: %d.\n", messagequeueid);
}

在運行上面這個程序之前,先使用命令touch messagequeuekey創建一個文件,然後多次執行的結果就會像下面這樣:

# ./a.out 
Message Queue key: 92536.
Message queue id: 32768.

System V IPC體系有一個統一的命令行工具:ipcmk,ipcs和ipcrm分別用於創建、查看和刪除IPC對象。例如,ipcs -q就能看到上面創建的消息隊列對象,如下所示:

# ipcs -q

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0x00016978 32768      root       777        0            0

接下來看如何發送信息。發送消息主要調用msgsnd函數。第一個參數是message queue的id,第二個參數是消息的結構體,第三個參數是消息的長度,最後一個參數是flag。這裏IPC_NOWAIT表示發送的時候不阻塞,直接返回。下面的這段程序,getopt_long、do-while循環以及switch,是用來解析命令行參數的:

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>
#include <getopt.h>
#include <string.h>


struct msg_buffer {
    long mtype;
    char mtext[1024];
};


int main(int argc, char *argv[]) {
  int next_option;
  const char* const short_options = "i:t:m:";
  const struct option long_options[] = {
    { "id", 1, NULL, 'i'},
    { "type", 1, NULL, 't'},
    { "message", 1, NULL, 'm'},
    { NULL, 0, NULL, 0 }
  };
  
  int messagequeueid = -1;
  struct msg_buffer buffer;
  buffer.mtype = -1;
  int len = -1;
  char * message = NULL;
  do {
    next_option = getopt_long (argc, argv, short_options, long_options, NULL);
    switch (next_option)
    {
      case 'i':
        messagequeueid = atoi(optarg);
        break;
      case 't':
        buffer.mtype = atol(optarg);
        break;
      case 'm':
        message = optarg;
        len = strlen(message) + 1;
        if (len > 1024) {
          perror("message too long.");
          exit(1);
        }
        memcpy(buffer.mtext, message, len);
        break;
      default:
        break;
    }
  }while(next_option != -1);


  if(messagequeueid != -1 && buffer.mtype != -1 && len != -1 && message != NULL){
    if(msgsnd(messagequeueid, &buffer, len, IPC_NOWAIT) == -1){
      perror("fail to send message.");
      exit(1);
    }
  } else {
    perror("arguments error");
  }
  
  return 0;
}

命令行參數的格式定義在long_options裏面。每一項的第一個成員“id”、“type“、“message”是參數選項的全稱,第二個成員都爲1,表示參數選項後面要跟參數,最後一個成員’i’、‘t’、'm’是參數選項的簡稱。接下來可以編譯並運行上面的發送程序,如下所示:

gcc -o send sendmessage.c
./send -i 32768 -t 123 -m "hello world"

接下來看如何收消息。收消息主要調用msgrcv函數,第一個參數是message queue的id,第二個參數是消息的結構體,第三個參數是可接受的最大長度,第四個參數是消息類型, 最後一個參數是flag,這裏IPC_NOWAIT表示接收的時候不阻塞,直接返回。接收消息的代碼實現如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <sys/msg.h>
#include <getopt.h>
#include <string.h>


struct msg_buffer {
    long mtype;
    char mtext[1024];
};


int main(int argc, char *argv[]) {
  int next_option;
  const char* const short_options = "i:t:";
  const struct option long_options[] = {
    { "id", 1, NULL, 'i'},
    { "type", 1, NULL, 't'},
    { NULL, 0, NULL, 0 }
  };
  
  int messagequeueid = -1;
  struct msg_buffer buffer;
  long type = -1;
  do {
    next_option = getopt_long (argc, argv, short_options, long_options, NULL);
    switch (next_option)
    {
      case 'i':
        messagequeueid = atoi(optarg);
        break;
      case 't':
        type = atol(optarg);
        break;
      default:
        break;
    }
  }while(next_option != -1);


  if(messagequeueid != -1 && type != -1){
    if(msgrcv(messagequeueid, &buffer, 1024, type, IPC_NOWAIT) == -1){
      perror("fail to recv message.");
      exit(1);
    }
    printf("received message type : %d, text: %s.", buffer.mtype, buffer.mtext);
  } else {
    perror("arguments error");
  }
  
  return 0;
}

接下來可以編譯並運行這個發送程序。可以看到,如果有消息,可以正確地讀到消息;如果沒有,則返回沒有消息,如下所示:

# ./recv -i 32768 -t 123
received message type : 123, text: hello world.
# ./recv -i 32768 -t 123
fail to recv message.: No message of desired type

3. 但是有時候,進程之間的溝通需要特別緊密,而且要分享一些比較大的數據。如果使用消息隊列,就發現一方面數據的來去不及時;另外一方面,數據大小也有限制,所以這個時候,經常採取的方式就是共享內存模型。以前提到內存管理的時候,知道每個進程都有自己獨立的虛擬內存空間,不同進程的虛擬內存空間映射到不同的物理內存中去。這個進程訪問A地址和另一個進程訪問A地址,其實訪問的是不同的物理內存地址,對於數據的增刪查改互不影響

但是可以變通一下,拿出一塊虛擬地址空間來,映射到相同的物理內存中。這樣這個進程寫入的東西,另外一個進程馬上就能看到了,就不需要互相拷貝來拷貝去。共享內存也是System V IPC進程間通信機制體系中的,所以從它使用流程可以看到熟悉的面孔。

創建一個共享內存可以調用shmget。在這個體系中,創建一個IPC對象都是xxxget,這裏面第一個參數是key,和msgget裏面的key一樣,都是唯一定位一個共享內存對象,也可以通過關聯文件的方式實現唯一性。第二個參數是共享內存的大小。第三個參數如果是IPC_CREAT,同樣表示創建一個新的,如下所示:

int shmget(key_t key, size_t size, int flag);

創建完畢之後,可以通過ipcs命令查看這個共享內存,如下所示:

#ipcs ­­--shmems

------ Shared Memory Segments ------ ­­­­­­­­
key        shmid    owner perms    bytes nattch status
0x00000000 19398656 marc  600    1048576 2      dest

接下來,如果一個進程想要訪問這一段共享內存,需要將這個內存加載到自己的虛擬地址空間的某個位置,通過shmat函數,就是attach的意思。其中addr就是要指定attach到這個地方。但是這個地址的設定難度比較大,除非對於內存佈局非常熟悉,否則可能會attach到一個非法地址。所以,通常的做法是將addr設爲NULL,讓內核選一個合適的地址,返回值就是真正被attach的地方,如下所示:

void *shmat(int shm_id, const void *addr, int flag);

如果共享內存使用完畢,可以通過shmdt解除綁定,然後通過shmctl將cmd設置爲IPC_RMID,從而刪除這個共享內存對象,如下所示:

int shmdt(void *addr); 

int shmctl(int shm_id, int cmd, struct shmid_ds *buf);

4. 如果兩個進程都同時寫一個地址,那先寫的那個進程會發現內容被別人覆蓋了。所以這裏就需要一種保護機制,使得同一個共享的資源,同一時間只能被一個進程訪問。在System V IPC進程間通信機制體系中,早就想好了應對辦法,就是信號量(Semaphore)。因此,信號量和共享內存往往要配合使用。信號量其實是一個計數器,主要用於實現進程間的互斥與同步,而不是用於存儲進程間通信數據

可以將信號量初始化爲一個數值,來代表某種資源的總體數量。對於信號量來講會定義兩種原子操作,一個是P操作,稱爲申請資源操作。這個操作會申請將信號量的數值減去N,表示這些數量被他申請使用了,其他人不能用了。另一個是V操作,稱爲歸還資源操作,這個操作會申請將信號量加上M,表示這些數量已經還給信號量了,其他人可以使用了。

所謂原子操作(Atomic Operation),就是任何一個最細單位的資源,都只能通過P操作借給某一個進程,不能同時借給兩個進程,必須分個先來後到。如果想創建一個信號量,可以通過semget函數,又是xxxget,第一個參數key也是類似的,第二個參數num_sems不是指資源的數量,而是表示可以創建多少個信號量,形成一組信號量,也就是說,如果有多種資源需要管理,可以創建一個信號量組,如下所示:

int semget(key_t key, int num_sems, int sem_flags);

接下來需要初始化信號量的總的資源數量。通過semctl函數,第一個參數semid是這個信號量組的id,第二個參數semnum纔是在這個信號量組中某個信號量的id,第三個參數是命令,如果是初始化則用SETVAL,第四個參數是一個union,如果初始化應該用裏面的val設置資源總量,如下所示:

int semctl(int semid, int semnum, int cmd, union semun args);


union semun
{
  int val;
  struct semid_ds *buf;
  unsigned short int *array;
  struct seminfo *__buf;
};

無論是P操作還是V操作,統一用semop函數。第一個參數還是信號量組的id,一次可以操作多個信號量。第三個參數numops就是有多少個操作,第二個參數將這些操作放在一個數組中,如下所示:

int semop(int semid, struct sembuf semoparray[], size_t numops);


struct sembuf 
{
  short sem_num; // 信號量組中對應的序號,0~sem_nums-1
  short sem_op;  // 信號量值在一次操作中的改變量
  short sem_flg; // IPC_NOWAIT, SEM_UNDO
}

數組的每一項是一個struct sembuf,裏面的第一個成員是這個操作的對象是哪個信號量。第二個成員就是要對這個信號量做多少改變。如果sem_op < 0,就請求sem_op的絕對值的資源。如果相應的資源數可以滿足請求,則將該信號量的值減去sem_op的絕對值,函數成功返回。

當相應的資源數不能滿足請求時,就要看sem_flg了。如果把sem_flg設置爲IPC_NOWAIT,也就是沒有資源也不等待,則semop函數出錯返回EAGAIN表示過會重試。如果sem_flg沒有指定IPC_NOWAIT,則進程掛起,直到當相應的資源數可以滿足請求。若sem_op > 0,表示進程歸還相應的資源數,將sem_op的值加到信號量的值上。如果有進程正在休眠等待此信號量,則喚醒它們。

5. 上面講的進程間通信的方式,都是常規狀態下的工作模式,就像平時的工作交接(管道),收發郵件(消息隊列)、聯合開發(共享內存與信號量)等,其實還有一種異常情況下的工作模式,叫信號。信號沒有特別複雜的數據結構,就是用一個代號一樣的數字。Linux提供了幾十種信號分別代表不同的意義。信號之間依靠它們的值來區分。信號可以在任何時候發送給某一進程,進程需要爲這個信號配置信號處理函數。當某個信號發生的時候,就立刻默認執行這個函數就可以了。這就相當於運維的系統應急手冊,當遇到什麼情況做什麼事情,都事先準備好,出了事情照着做就可以了。

二、信號

6. 在Linux操作系統中,爲了響應各種各樣的事件,也是定義了非常多的信號。可以通過kill -l命令,查看所有的信號:

# kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

這些信號都是什麼作用呢?可以通過man 7 signal命令查看,裏面會有一個列表:

Signal     Value     Action   Comment
──────────────────────────────────────────────────────────────────────
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction


SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no
                              readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
……

就像應急預案裏面給出的一樣,每個信號都有一個唯一的ID,還有遇到這個信號時的默認操作。一旦有信號產生,就有下面這幾種用戶進程對信號的處理方式:

(1)執行默認操作。Linux對每種信號都規定了默認操作,例如上面列表中的Term,就是終止進程的意思。Core 的意思是Core Dump,也即終止進程後,通過Core Dump將當前進程的運行狀態保存在文件裏面,方便程序員事後進行分析問題在哪裏。

(2)捕捉信號。可以爲信號定義一個信號處理函數,當信號發生時就執行相應的信號處理函數。

(3)忽略信號。當不希望處理某些信號的時候,就可以忽略該信號不做任何處理。有兩個信號是應用進程無法捕捉和忽略的,即SIGKILL和SEGSTOP,它們用於在任何時候中斷或結束某一進程

7. 接下來看一下信號處理最常見的流程。這個過程主要是分成兩步,第一步是註冊信號處理函數。第二步是發送信號。現在先主要看第一步。如果不想讓某個信號執行默認操作,一種方法就是對特定的信號註冊相應的信號處理函數,設置信號處理方式的是signal函數,如下所示:

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

這其實就是定義一個方法,並且將這個方法和某個信號關聯起來。當這個進程遇到這個信號的時候,就執行這個方法。如果在Linux下面執行man signal的話,會發現Linux不建議直接用這個方法,而是改用sigaction,定義如下:

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);

這兩者的區別在哪裏呢?其實它還是將信號和一個動作進行關聯,只不過這個動作由一個結構struct sigaction表示了,如下所示:

struct sigaction {
  __sighandler_t sa_handler;
  unsigned long sa_flags;
  __sigrestore_t sa_restorer;
  sigset_t sa_mask;    /* mask last for extensibility */
};

和signal類似的是,這裏面還是有__sighandler_t。但是,其他成員變量可以更加細緻地控制信號處理的行爲,而signal 函數沒有機會設置這些。需要注意的是,signal不是系統調用,而是glibc封裝的一個函數。這樣就像man signal裏面寫的一樣,不同的實現方式設置的參數會不同,會導致行爲的不同。例如在glibc裏面會看到了這樣一個實現:

#  define signal __sysv_signal
__sighandler_t
__sysv_signal (int sig, __sighandler_t handler)
{
  struct sigaction act, oact;
......
  act.sa_handler = handler;
  __sigemptyset (&act.sa_mask);
  act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
  act.sa_flags &= ~SA_RESTART;
  if (__sigaction (sig, &act, &oact) < 0)
    return SIG_ERR;
  return oact.sa_handler;
}
weak_alias (__sysv_signal, sysv_signal)

在這裏面sa_flags進行了默認的設置。SA_ONESHOT的意思就是,這裏設置的信號處理函數僅僅起作用一次。用完了一次後,就設置回默認行爲。這其實並不是用戶想看到的,畢竟一旦安裝了一個信號處理函數,肯定希望它一直起作用,直到顯式地關閉它。

另外一個設置就是SA_NOMASK即不一次後重置。通過__sigemptyset,將sa_mask設置爲空,這樣的設置表示在這個信號處理函數執行過程中,如果再有其他信號,哪怕相同的信號到來時,這個信號處理函數會被中斷。如果一個信號處理函數真的被其他信號中斷,其實問題也不大,因爲當處理完了其他的信號處理函數後,還會回來接着處理這個信號處理函數。但是對於相同的信號就有點尷尬了,這就需要這個信號處理函數寫的比較有技巧了。

例如,對於這個信號的處理過程中,要操作某個數據結構,因爲是相同的信號,很可能操作的是同一個實例,這樣同步、死鎖這些都要想好。其實一般的思路應該是,當某一個信號的信號處理函數運行的時候,暫時屏蔽這個信號的再一次響應。屏蔽並不意味着信號一定丟失,而是暫存,這樣能夠做到信號處理函數對於相同的信號,處理完一個再處理下一個,這樣信號處理函數的邏輯要簡單得多。

還有一個設置就是設置了SA_INTERRUPT,清除了SA_RESTART。這是什麼意思呢?我們知道信號的到來時間是不可預期的,有可能程序正在調用某個漫長系統調用的時候(可以運行man 7 signal命令,在這裏找Interruption of system calls and library functions by signal handlers的部分,裏面說的非常詳細),這個時候一個信號來了,會中斷這個系統調用,去執行信號處理函數。

那執行完了系統調用怎麼辦呢?這時候有兩種處理方法,一種就是SA_INTERRUPT,即系統調用被中斷了就不再重試這個系統調用了,而是直接返回一個-EINTR常量,告訴調用方這個系統調用被信號中斷了,但是怎麼處理自己看着辦。如果是這樣的話,調用方可以根據自己的邏輯,重新調用或者直接返回,這會使得代碼非常複雜,在所有系統調用的返回值判斷裏面,都要特殊判斷一下這個值。

另外一種處理方法是SA_RESTART。這個時候系統調用會被自動重新啓動,不需要調用方自己寫代碼。當然也可能存在問題,例如從終端讀入一個字符,這個時候用戶在終端輸入一個'a'字符,在處理'a'字符的時候被信號中斷了,等信號處理完畢,再次讀入一個字符的時候,如果用戶不再輸入,就停在那裏了,需要用戶再次輸入同一個字符。因而建議使用sigaction函數,根據自己的需要定製參數。

8. 接下來看sigaction具體做了些什麼。還記得系統調用中glibc裏面有個文件syscalls.list,這裏面定義了庫函數調用哪些系統調用,在這裏找到了sigaction,如下所示:

sigaction    -       sigaction       i:ipp   __sigaction     sigaction

接下來在glibc中,__sigaction會調 __libc_sigaction,並最終調用的系統調用是rt_sigaction,如下所示:

int
__sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
......
  return __libc_sigaction (sig, act, oact);
}


int
__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
  int result;
  struct kernel_sigaction kact, koact;


  if (act)
    {
      kact.k_sa_handler = act->sa_handler;
      memcpy (&kact.sa_mask, &act->sa_mask, sizeof (sigset_t));
      kact.sa_flags = act->sa_flags | SA_RESTORER;


      kact.sa_restorer = &restore_rt;
    }


  result = INLINE_SYSCALL (rt_sigaction, 4,
                           sig, act ? &kact : NULL,
                           oact ? &koact : NULL, _NSIG / 8);
  if (oact && result >= 0)
    {
      oact->sa_handler = koact.k_sa_handler;
      memcpy (&oact->sa_mask, &koact.sa_mask, sizeof (sigset_t));
      oact->sa_flags = koact.sa_flags;
      oact->sa_restorer = koact.sa_restorer;
    }
  return result;
}

這也是很多人看信號處理內核實現的時候,比較困惑的地方。例如內核代碼註釋裏面會說,系統調用signal是爲了兼容過去,系統調用sigaction也是爲了兼容過去,連參數都變成了struct compat_old_sigaction,所以說庫函數雖然調用的是sigaction,到了系統調用層調用的可不是系統調用sigaction,而是系統調用rt_sigaction,如下所示:

SYSCALL_DEFINE4(rt_sigaction, int, sig,
    const struct sigaction __user *, act,
    struct sigaction __user *, oact,
    size_t, sigsetsize)
{
  struct k_sigaction new_sa, old_sa;
  int ret = -EINVAL;
......
  if (act) {
    if (copy_from_user(&new_sa.sa, act, sizeof(new_sa.sa)))
      return -EFAULT;
  }


  ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);


  if (!ret && oact) {
    if (copy_to_user(oact, &old_sa.sa, sizeof(old_sa.sa)))
      return -EFAULT;
  }
out:
  return ret;
}

在rt_sigaction裏面,將用戶態的struct sigaction結構拷貝爲內核態的k_sigaction,然後調用do_sigaction。do_sigaction也很簡單,還記得進程內核的數據結構裏,struct task_struct裏面有一個成員sighand,裏面有一個action,這是一個數組,下標是信號,內容就是信號處理函數,do_sigaction就是設置sighand裏的信號處理函數,如下所示:

int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
  struct task_struct *p = current, *t;
  struct k_sigaction *k;
  sigset_t mask;
......
  k = &p->sighand->action[sig-1];


  spin_lock_irq(&p->sighand->siglock);
  if (oact)
    *oact = *k;


  if (act) {
    sigdelsetmask(&act->sa.sa_mask,
            sigmask(SIGKILL) | sigmask(SIGSTOP));
    *k = *act;
......
  }


  spin_unlock_irq(&p->sighand->siglock);
  return 0;
}

至此,信號處理函數的註冊已經完成了。

9. 上面講了如何通過API註冊一個信號處理函數,整個過程如下圖所示:

(1)在用戶程序裏面有兩個函數可以調用,一個是signal,一個是sigaction,推薦使用sigaction。

(2)用戶程序調用的是Glibc裏面的函數,signal調用的是__sysv_signal,裏面默認設置了一些參數,使得signal的功能受到了限制,sigaction調用的是__sigaction,參數用戶可以任意設定。

(3)無論是__sysv_signal還是__sigaction,調用的都是統一的一個系統調用rt_sigaction。

(4)在內核中,rt_sigaction調用的是do_sigaction設置信號處理函數。在每一個進程的task_struct裏面,都有一個sighand指向struct sighand_struct,裏面是一個數組,下標是信號,裏面的內容是信號處理函數。

10. 有時候在終端輸入某些組合鍵時,會給進程發送信號,例如Ctrl+C產生SIGINT信號,Ctrl+Z產生SIGTSTP信號。有時硬件異常也會產生信號,比如執行了除以0的指令,CPU就會產生異常,然後把SIGFPE信號發送給進程。再比如進程訪問了非法內存,內存管理模塊就會產生異常,然後把信號SIGSEGV發送給進程

這裏同樣是硬件產生的,對於中斷和信號還是要加以區別。前面講過中斷要註冊中斷處理函數,但是中斷處理函數是在內核驅動裏面的,信號也要註冊信號處理函數,信號處理函數是在用戶態進程裏面的。對於硬件觸發的,無論是中斷還是信號,肯定是先到內核的,然後內核對於中斷和信號處理方式不同。一個是完全在內核裏面處理完畢,一個是將信號放在對應的進程task_struct裏信號相關的數據結構裏面,然後等待進程在用戶態去處理。

當然有些嚴重的信號,內核會把進程幹掉。但是這也能看出來,中斷和信號的嚴重程度不一樣,信號影響的往往是某一個進程,處理慢了甚至錯了,也不過這個進程被幹掉,而中斷影響的是整個系統,一旦中斷處理中有了bug,可能整個Linux都掛了

有時候內核在某些情況下,也會給進程發送信號。例如向讀端已關閉的管道寫數據時產生SIGPIPE信號,當子進程退出時要給父進程發送SIG_CHLD信號等。最直接的發送信號的方法就是,通過命令kill來發送信號了。例如kill -9 pid可以發送信號給一個進程殺死它。另外還可以通過kill或者sigqueue系統調用,發送信號給某個進程,也可以通過tkill或者tgkill發送信號給某個線程。雖然方式多種多樣,但是最終都是調用了do_send_sig_info函數,將信號放在相應的task_struct的信號數據結構中。do_send_sig_info會調用send_signal,進而調用__send_signal,如下所示:

SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
  struct siginfo info;

  info.si_signo = sig;
  info.si_errno = 0;
  info.si_code = SI_USER;
  info.si_pid = task_tgid_vnr(current);
  info.si_uid = from_kuid_munged(current_user_ns(), current_uid());

  return kill_something_info(sig, &info, pid);
}


static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
      int group, int from_ancestor_ns)
{
  struct sigpending *pending;
  struct sigqueue *q;
  int override_rlimit;
  int ret = 0, result;
......
  pending = group ? &t->signal->shared_pending : &t->pending;
......
  if (legacy_queue(pending, sig))
    goto ret;

  if (sig < SIGRTMIN)
    override_rlimit = (is_si_special(info) || info->si_code >= 0);
  else
    override_rlimit = 0;

  q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
    override_rlimit);
  if (q) {
    list_add_tail(&q->list, &pending->list);
    switch ((unsigned long) info) {
    case (unsigned long) SEND_SIG_NOINFO:
      q->info.si_signo = sig;
      q->info.si_errno = 0;
      q->info.si_code = SI_USER;
      q->info.si_pid = task_tgid_nr_ns(current,
              task_active_pid_ns(t));
      q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
      break;
    case (unsigned long) SEND_SIG_PRIV:
      q->info.si_signo = sig;
      q->info.si_errno = 0;
      q->info.si_code = SI_KERNEL;
      q->info.si_pid = 0;
      q->info.si_uid = 0;
      break;
    default:
      copy_siginfo(&q->info, info);
      if (from_ancestor_ns)
        q->info.si_pid = 0;
      break;
    }

    userns_fixup_signal_uid(&q->info, t);

  } 
......
out_set:
  signalfd_notify(t, sig);
  sigaddset(&pending->signal, sig);
  complete_signal(sig, t, group);
ret:
  return ret;
}

在這裏看到,在進程數據結構中task_struct裏面的sigpending。在上面代碼中,先是要決定應該用哪個sigpending,這就要看發送的信號是給進程的還是線程的。如果是kill發送的,也就是發送給整個進程的,就應該發送給 t->signal->shared_pending,這裏面是整個進程所有線程共享的信號;如果是tkill發送的,也就是發給某個線程的,就應該發給t->pending,這裏是這個線程的task_struct獨享的信號。struct sigpending裏面有兩個成員,一個是一個集合sigset_t,表示都收到了哪些信號,還有一個鏈表也表示收到了哪些信號,它的結構如下:

struct sigpending {
  struct list_head list;
  sigset_t signal;
};

如果都表示收到了信號,這兩者有什麼區別呢?接着往下看__send_signal裏的代碼。接下來要調用legacy_queue,如果滿足條件那就直接退出。那legacy_queue裏面判斷的是什麼條件呢?來看它的代碼:

static inline int legacy_queue(struct sigpending *signals, int sig)
{
  return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
}


#define SIGRTMIN  32
#define SIGRTMAX  _NSIG
#define _NSIG    64

當信號小於SIGRTMIN,也即32時,如果發現這個信號已經在集合裏面了,就直接退出了,這樣會造成信號的丟失。例如發送給進程100個SIGUSR1(對應的信號爲10),那最終能夠被信號處理函數處理的信號有多少就不好說了,比如總共5個SIGUSR1,分別是A、B、C、D、E。如果這五個信號來得太密,A來了但是信號處理函數還沒來得及處理,B、C、D、E就都來了。根據上面的邏輯,因爲A已經將SIGUSR1放在sigset_t集合中了,因而後面四個都要丟失。

如果是另一種情況,A來了已經被信號處理函數處理了,內核在調用信號處理函數之前,會將集合中的標誌位清除,這個時候B再來,B還是會進入集合,還是會被處理,也就不會丟。這樣信號能夠處理多少,和信號處理函數什麼時候被調用,信號多大頻率被髮送,都有關係,而且從後面的分析可以知道,信號處理函數的調用時間也是不確定的。因此小於32的信號如此不靠譜,就稱它爲不可靠信號

11. 如果大於32的信號是什麼情況呢?接下來__sigqueue_alloc會分配一個struct sigqueue對象,然後通過list_add_tail掛在struct sigpending裏面的鏈表上。這樣就靠譜多了。如果發送過來100個信號,變成鏈表上的100項都不會丟,哪怕相同的信號發送多遍,也處理多遍。因此,大於32的信號稱爲可靠信號。當然隊列的長度也是有限的,如果執行ulimit命令可以看到,這個限制pending signals (-i) 15408。當信號掛到了task_struct結構之後,最後需要調用complete_signal。這裏面的邏輯也很簡單,就是說既然這個進程有了一個新的信號,趕緊找一個線程處理一下吧:

static void complete_signal(int sig, struct task_struct *p, int group)
{
  struct signal_struct *signal = p->signal;
  struct task_struct *t;

  /*
   * Now find a thread we can wake up to take the signal off the queue.
   *
   * If the main thread wants the signal, it gets first crack.
   * Probably the least surprising to the average bear.
   */
  if (wants_signal(sig, p))
    t = p;
  else if (!group || thread_group_empty(p))
    /*
     * There is just one thread and it does not need to be woken.
     * It will dequeue unblocked signals before it runs again.
     */
    return;
  else {
    /*
     * Otherwise try to find a suitable thread.
     */
    t = signal->curr_target;
    while (!wants_signal(sig, t)) {
      t = next_thread(t);
      if (t == signal->curr_target)
        return;
    }
    signal->curr_target = t;
  }
......
  /*
   * The signal is already in the shared-pending queue.
   * Tell the chosen thread to wake up and dequeue it.
   */
  signal_wake_up(t, sig == SIGKILL);
  return;
}

在找到了一個進程或者線程的task_struct之後,要調用signal_wake_up來企圖喚醒它,signal_wake_up會調用signal_wake_up_state,如下所示:

void signal_wake_up_state(struct task_struct *t, unsigned int state)
{
  set_tsk_thread_flag(t, TIF_SIGPENDING);


  if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
    kick_process(t);
}

signal_wake_up_state裏面主要做了兩件事情。第一就是給這個線程設置TIF_SIGPENDING表示信號來了,這就說明其實信號的處理和進程的調度是採取這樣一種類似的機制:當發現一個進程應該被調度的時候,並不直接把它趕下來,而是設置一個標識位TIF_NEED_RESCHED表示等待調度,然後等待系統調用結束或者中斷處理結束,從內核態返回用戶態的時候,調用schedule函數進行調度。信號也是類似的,當信號來的時候並不直接處理這個信號,而是設置一個標識位TIF_SIGPENDING,來表示已經有信號等待處理,同樣等待系統調用結束,或者中斷處理結束,從內核態返回用戶態的時候,再進行信號的處理

signal_wake_up_state的第二件事情,就是試圖喚醒這個進程或者線程。wake_up_state會調用try_to_wake_up方法,就是將這個進程或者線程設置爲TASK_RUNNING狀態準備被調度,然後放在運行隊列中,這個時候隨着時鐘不斷的滴答,遲早會被調用。如果wake_up_state返回0,說明進程或者線程已經是TASK_RUNNING狀態了,如果它在另外一個CPU上運行,則調用kick_process發送一個處理器間中斷,強制那個進程或者線程重新調度,重新調度完畢後,會返回用戶態運行,這是一個時機,會檢查TIF_SIGPENDING標識位。

12. 這樣,信號已經發送到位了,什麼時候真正處理它呢?就是在從系統調用或者中斷返回的時候,無論是從系統調用返回還是從中斷返回,都會調用exit_to_usermode_loop,接下來重點關注_TIF_SIGPENDING標識位,如下所示:

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
  while (true) {
......
    if (cached_flags & _TIF_NEED_RESCHED)
      schedule();
......
    /* deal with pending signal delivery */
    if (cached_flags & _TIF_SIGPENDING)
      do_signal(regs);
......
    if (!(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
      break;
  }
}

如果在前一個環節中已經設置了_TIF_SIGPENDING,就會調用do_signal進行處理,如下所示:

void do_signal(struct pt_regs *regs)
{
  struct ksignal ksig;

  if (get_signal(&ksig)) {
    /* Whee! Actually deliver the signal.  */
    handle_signal(&ksig, regs);
    return;
  }

  /* Did we come from a system call? */
  if (syscall_get_nr(current, regs) >= 0) {
    /* Restart the system call - no handlers present */
    switch (syscall_get_error(current, regs)) {
    case -ERESTARTNOHAND:
    case -ERESTARTSYS:
    case -ERESTARTNOINTR:
      regs->ax = regs->orig_ax;
      regs->ip -= 2;
      break;

    case -ERESTART_RESTARTBLOCK:
      regs->ax = get_nr_restart_syscall(regs);
      regs->ip -= 2;
      break;
    }
  }
  restore_saved_sigmask();
}

do_signal會調用handle_signal。按理說,信號處理就是調用用戶提供的信號處理函數,但是這事兒沒有看起來這麼簡單,因爲信號處理函數是在用戶態的,這裏還在內核態沒法直接調用。這樣又要來回憶系統調用的過程了,這個進程當時在用戶態執行到某一行Line A,調用了一個系統調用,在進入內核的那一刻,在內核pt_regs裏面保存了用戶態執行到Line A這個狀態。現在從系統調用返回用戶態了,按說應該從pt_regs拿出Line A,然後接着Line A執行下去,但是爲了響應信號不能回到用戶態的時候返回Line A了,而是應該返回信號處理函數的起始地址。handle_signal的實現如下所示:

static void
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
  bool stepping, failed;
......
  /* Are we from a system call? */
  if (syscall_get_nr(current, regs) >= 0) {
    /* If so, check system call restarting.. */
    switch (syscall_get_error(current, regs)) {
    case -ERESTART_RESTARTBLOCK:
    case -ERESTARTNOHAND:
      regs->ax = -EINTR;
      break;
    case -ERESTARTSYS:
      if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
        regs->ax = -EINTR;
        break;
      }
    /* fallthrough */
    case -ERESTARTNOINTR:
      regs->ax = regs->orig_ax;
      regs->ip -= 2;
      break;
    }
  }
......
  failed = (setup_rt_frame(ksig, regs) < 0);
......
  signal_setup_done(failed, ksig, stepping);
}

這個時候就需要干預和自己來定製pt_regs了。這時要看是否從系統調用中返回。如果是從系統調用返回的話,還要區分是從系統調用中正常返回,還是在一個非運行狀態的系統調用中,因爲被信號中斷而返回。這裏解析一個最複雜的場景,還記得以前解析進程調度時舉的一個例子,就是從一個tap網卡中讀取數據。當時主要關注schedule那一行,即如果發現沒有數據的時候就調用schedule(),自己進入等待狀態然後將CPU讓給其他進程。具體的代碼如下:

static ssize_t tap_do_read(struct tap_queue *q,
         struct iov_iter *to,
         int noblock, struct sk_buff *skb)
{
......
  while (1) {
    if (!noblock)
      prepare_to_wait(sk_sleep(&q->sk), &wait,
          TASK_INTERRUPTIBLE);

    /* Read frames from the queue */
    skb = skb_array_consume(&q->skb_array);
    if (skb)
      break;
    if (noblock) {
      ret = -EAGAIN;
      break;
    }
    if (signal_pending(current)) {
      ret = -ERESTARTSYS;
      break;
    }
    /* Nothing to read, let's sleep */
    schedule();
  }
......
}

這裏關注和信號相關的部分,這其實是一個信號中斷系統調用的典型邏輯。首先把當前進程或者線程的狀態設置爲TASK_INTERRUPTIBLE,這樣才能是使這個系統調用可以被中斷。其次,可以被中斷的系統調用往往是比較慢的調用,並且會因爲數據不就緒而通過schedule讓出CPU進入等待狀態。在發送信號的時候,除了設置這個進程和線程的_TIF_SIGPENDING標識位之外,還試圖喚醒這個進程或者線程,也就是將它從TASK_INTERRUPTIBLE等待狀態中設置爲TASK_RUNNING。

當這個進程或者線程再次運行的時候,會從schedule函數中返回,然後再次進入while循環。由於這個進程或者線程是由信號喚醒的,而不是因爲數據來了而喚醒的,因而是讀不到數據的,但是在signal_pending函數中檢測到了_TIF_SIGPENDING標識位,這說明系統調用沒有真的做完,於是返回一個錯誤ERESTARTSYS,然後帶着這個錯誤從系統調用返回。

然後到了exit_to_usermode_loop->do_signal->handle_signal。在這裏面當發現出現錯誤ERESTARTSYS的時候,就知道這是從一個沒有調用完的系統調用返回的,設置系統調用錯誤碼EINTR。接下來就開始折騰pt_regs了,主要通過調用setup_rt_frame->__setup_rt_frame,如下所示:

static int __setup_rt_frame(int sig, struct ksignal *ksig,
          sigset_t *set, struct pt_regs *regs)
{
  struct rt_sigframe __user *frame;
  void __user *fp = NULL;
  int err = 0;

  frame = get_sigframe(&ksig->ka, regs, sizeof(struct rt_sigframe), &fp);
......
  put_user_try {
......
    /* Set up to return from userspace.  If provided, use a stub
       already in userspace.  */
    /* x86-64 should always use SA_RESTORER. */
    if (ksig->ka.sa.sa_flags & SA_RESTORER) {
      put_user_ex(ksig->ka.sa.sa_restorer, &frame->pretcode);
    } 
  } put_user_catch(err);

  err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);
  err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));

  /* Set up registers for signal handler */
  regs->di = sig;
  /* In case the signal handler was declared without prototypes */
  regs->ax = 0;

  regs->si = (unsigned long)&frame->info;
  regs->dx = (unsigned long)&frame->uc;
  regs->ip = (unsigned long) ksig->ka.sa.sa_handler;

  regs->sp = (unsigned long)frame;
  regs->cs = __USER_CS;
......
  return 0;
}

frame的類型是rt_sigframe,這個frame就是一個棧幀。在get_sigframe中會得到pt_regs的sp變量,也就是原來這個程序在用戶態的棧頂指針,然後get_sigframe中會將sp減去sizeof(struct rt_sigframe),也就是把這個棧幀塞到了棧裏面,然後又在__setup_rt_frame中把regs->sp設置成等於frame。這就相當於強行在程序原來的用戶態的棧裏面插入了一個棧幀,並在最後將regs->ip設置爲用戶定義的信號處理函數sa_handler。這意味着本來返回用戶態應該接着原來的代碼執行的,現在不了要執行sa_handler了。執行完了以後按照函數棧的規則,彈出上一個棧幀來,也就是彈出了之前硬塞進去的frame。

13. 那如果假設sa_handler成功返回了,怎麼回到程序原來在用戶態運行的地方呢?玄機就在frame裏面。要想恢復原來運行的地方,首先原來的pt_regs不能丟,是在setup_sigcontext裏面,將原來的pt_regs保存在了frame中的uc_mcontext裏面。

另外很重要的一點,程序如何跳過去呢?在__setup_rt_frame中還有一個不引起重視的操作,那就是通過put_user_ex,將sa_restorer放到了frame->pretcode裏面,而且還是按照函數棧的規則,函數棧裏面包含了函數執行完跳回去的地址。當sa_handler執行完之後,彈出的函數棧是frame,也就應該跳到sa_restorer的地址。

這是什麼地址呢?在前面sigaction介紹的時候就沒有介紹它,在Glibc的__libc_sigaction函數中也沒有注意到,它被賦值成了restore_rt。這其實就是sa_handler執行完畢之後,馬上要執行的函數。從名字就能感覺到,它將恢復原來程序運行的地方。在Glibc中可以找到它的定義,它竟然調用了一個系統調用,系統調用號爲__NR_rt_sigreturn,如下所示:

RESTORE (restore_rt, __NR_rt_sigreturn)

#define RESTORE(name, syscall) RESTORE2 (name, syscall)
# define RESTORE2(name, syscall) \
asm                                     \
  (                                     \
   ".LSTART_" #name ":\n"               \
   "    .type __" #name ",@function\n"  \
   "__" #name ":\n"                     \
   "    movq $" #syscall ", %rax\n"     \
   "    syscall\n"                      \
......

可以在內核裏面找到__NR_rt_sigreturn對應的系統調用,如下所示:

asmlinkage long sys_rt_sigreturn(void)
{
  struct pt_regs *regs = current_pt_regs();
  struct rt_sigframe __user *frame;
  sigset_t set;
  unsigned long uc_flags;

  frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));
  if (__copy_from_user(&set, &frame->uc.uc_sigmask, sizeof(set)))
    goto badframe;
  if (__get_user(uc_flags, &frame->uc.uc_flags))
    goto badframe;

  set_current_blocked(&set);

  if (restore_sigcontext(regs, &frame->uc.uc_mcontext, uc_flags))
    goto badframe;
......
  return regs->ax;
......
}

在這裏面,把上次填充的那個rt_sigframe拿出來,然後restore_sigcontext將pt_regs恢復成爲原來用戶態的樣子。從這個系統調用返回的時候,應用還誤以爲從上次的系統調用返回的。至此,整個信號處理過程才全部結束。

14. 信號的發送與處理是一個複雜的過程,這裏來總結一下,如下圖所示:

(1)假設有一個進程A,main函數裏面調用系統調用進入內核。

(2)按照系統調用的原理,會將用戶態棧的信息保存在pt_regs裏面,即記住原來用戶態是運行到了line A的地方。

(3)在內核中執行系統調用讀取數據。

(4)當發現沒有什麼數據可讀取的時候,只好進入睡眠狀態,並且調用schedule讓出CPU。

(5)將進程狀態設置爲TASK_INTERRUPTIBLE即可中斷的睡眠狀態,也就是如果有信號來的話是可以喚醒它的。

(6)其他的進程或者shell發送一個信號,有四個函數可以調用:kill、tkill、tgkill、rt_sigqueueinfo。

(7)四個發送信號的函數,在內核中最終都是調用do_send_sig_info。

(8)do_send_sig_info調用send_signal給進程A發送一個信號,其實就是找到進程A的task_struct,或者加入信號集合成爲不可靠信號,或者加入信號鏈表成爲可靠信號。

(9)do_send_sig_info調用signal_wake_up喚醒進程A。

(10)進程A重新進入運行狀態TASK_RUNNING,一定會接着schedule運行。

(11)進程A被喚醒後,檢查是否有信號到來,如果沒有重新循環到一開始,嘗試再次讀取數據,如果還是沒有數據,再次進入TASK_INTERRUPTIBLE即可中斷的睡眠狀態。

(12)當發現有信號到來的時候,就返回當前正在執行的系統調用,並返回一個錯誤表示系統調用被中斷了。

(13)系統調用返回的時候,會調用exit_to_usermode_loop。這是一個處理信號的時機。

(14)調用do_signal開始處理信號。

(15)根據信號,得到信號處理函數sa_handler,然後修改pt_regs中的用戶態棧的信息,讓pt_regs指向sa_handler。同時修改用戶態的棧,插入一個棧幀sa_restorer,裏面保存了原來的指向line A的pt_regs,並且設置讓sa_handler運行完畢後,跳到sa_restorer運行。

(16)返回用戶態,由於pt_regs已經設置爲sa_handler,則返回用戶態執行sa_handler。

(17)sa_handler 執行完畢後,信號處理函數就執行完了,接着根據第15步對於用戶態棧幀的修改,會跳到sa_restorer 運行。

(18)sa_restorer會調用系統調用rt_sigreturn再次進入內核。

(19)在內核中rt_sigreturn恢復原來的pt_regs,重新指向line A。

(20)從rt_sigreturn返回用戶態,還是調用exit_to_usermode_loop。

(21)這次因爲pt_regs已經指向line A了,於是就到了進程A中,接着系統調用之後運行,當然這個系統調用返回的是它被中斷了沒有執行完的錯誤。

三、管道

15. 先來看常用的匿名管道(Anonymous Pipes),即把多個命令串起來的豎線,背後的原理到底是什麼。上次提到它是基於管道的,那管道如何創建呢?需要通過下面這個系統調用:

int pipe(int fd[2])

在這裏創建了一個管道pipe,返回了兩個文件描述符,這表示管道的兩端,一個是管道的讀取端描述符fd[0],另一個是管道的寫入端描述符fd[1]。來看在內核裏面是如何實現的,如下所示:

SYSCALL_DEFINE1(pipe, int __user *, fildes)
{
  return sys_pipe2(fildes, 0);
}

SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags)
{
  struct file *files[2];
  int fd[2];
  int error;

  error = __do_pipe_flags(fd, files, flags);
  if (!error) {
    if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {
......
      error = -EFAULT;
    } else {
      fd_install(fd[0], files[0]);
      fd_install(fd[1], files[1]);
    }
  }
  return error;
}

在內核中,主要的邏輯在pipe2系統調用中。這裏面要創建一個數組files,用來存放管道的兩端的打開文件,另一個數組fd存放管道兩端的文件描述符。如果調用__do_pipe_flags沒有錯誤,那就調用fd_install,將兩個fd和兩個struct file關聯起來,這一點和打開一個文件的過程很像了。來看__do_pipe_flags,這裏面調用了create_pipe_files,然後生成了兩個fd。從這裏可以看出,fd[0]是用於讀的,fd[1]是用於寫的,如下所示:

static int __do_pipe_flags(int *fd, struct file **files, int flags)
{
  int error;
  int fdw, fdr;
......
  error = create_pipe_files(files, flags);
......
  error = get_unused_fd_flags(flags);
......
  fdr = error;

  error = get_unused_fd_flags(flags);
......
  fdw = error;

  fd[0] = fdr;
  fd[1] = fdw;
  return 0;
......
}

創建一個管道,大部分的邏輯其實都是在create_pipe_files函數裏面實現的。之前提過,命名管道是創建在文件系統上的。從這裏可以看出,匿名管道也是創建在文件系統上的,只不過是一種特殊的文件系統,創建一個特殊的文件,對應一個特殊的inode,就是這裏面的get_pipe_inode,如下所示:

int create_pipe_files(struct file **res, int flags)
{
  int err;
  struct inode *inode = get_pipe_inode();
  struct file *f;
  struct path path;
......
  path.dentry = d_alloc_pseudo(pipe_mnt->mnt_sb, &empty_name);
......
  path.mnt = mntget(pipe_mnt);

  d_instantiate(path.dentry, inode);

  f = alloc_file(&path, FMODE_WRITE, &pipefifo_fops);
......
  f->f_flags = O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT));
  f->private_data = inode->i_pipe;

  res[0] = alloc_file(&path, FMODE_READ, &pipefifo_fops);
......
  path_get(&path);
  res[0]->private_data = inode->i_pipe;
  res[0]->f_flags = O_RDONLY | (flags & O_NONBLOCK);
  res[1] = f;
  return 0;
......
}

從下面get_pipe_inode的實現可以看出,匿名管道來自一個特殊的文件系統pipefs。這個文件系統被掛載後,就得到了struct vfsmount *pipe_mnt,然後掛載的文件系統的superblock就變成了:pipe_mnt->mnt_sb,如下所示:

static struct file_system_type pipe_fs_type = {
  .name    = "pipefs",
  .mount    = pipefs_mount,
  .kill_sb  = kill_anon_super,
};

static int __init init_pipe_fs(void)
{
  int err = register_filesystem(&pipe_fs_type);

  if (!err) {
    pipe_mnt = kern_mount(&pipe_fs_type);
  }
......
}

static struct inode * get_pipe_inode(void)
{
  struct inode *inode = new_inode_pseudo(pipe_mnt->mnt_sb);
  struct pipe_inode_info *pipe;
......
  inode->i_ino = get_next_ino();

  pipe = alloc_pipe_info();
......
  inode->i_pipe = pipe;
  pipe->files = 2;
  pipe->readers = pipe->writers = 1;
  inode->i_fop = &pipefifo_fops;
  inode->i_state = I_DIRTY;
  inode->i_mode = S_IFIFO | S_IRUSR | S_IWUSR;
  inode->i_uid = current_fsuid();
  inode->i_gid = current_fsgid();
  inode->i_atime = inode->i_mtime = inode->i_ctime = current_time(inode);

  return inode;
......
}

從new_inode_pseudo函數創建一個inode,這裏面開始填寫inode的成員,這裏和文件系統的很像,這裏值得注意的是struct pipe_inode_info,這個結構裏面有個成員是struct pipe_buffer *bufs。因此可以知道,所謂的匿名管道,其實就是內核裏面的一串緩存

16. 另外一個需要注意的是pipefifo_fops,將來對於文件描述符的操作,在內核裏面都是對應這裏面的操作,如下所示:

const struct file_operations pipefifo_fops = {
  .open            = fifo_open,
  .llseek          = no_llseek,
  .read_iter       = pipe_read,
  .write_iter      = pipe_write,
  .poll            = pipe_poll,
  .unlocked_ioctl  = pipe_ioctl,
  .release         = pipe_release,
  .fasync          = pipe_fasync,
};

回到create_pipe_files函數,創建完了inode,還需創建一個dentry和它對應。dentry和inode對應好了,就要開始創建struct file對象了。先創建用於寫入的,對應的操作爲pipefifo_fops;再創建讀取的,對應的操作也爲pipefifo_fops。然後把private_data設置爲pipe_inode_info,這樣從struct file這個層級上,就能直接操作底層的讀寫操作。

至此,一個匿名管道就創建成功了。如果對於fd[1]寫入,調用的是pipe_write,向pipe_buffer裏面寫入數據;如果對於fd[0]的讀入,調用的是pipe_read,也就是從pipe_buffer裏面讀取數據。但是這個時候,兩個文件描述符都是在一個進程裏面的,並沒有起到進程間通信的作用,怎麼樣才能使得管道是跨兩個進程的呢?

還記得創建進程調用的fork嗎?在這裏面,創建的子進程會複製父進程的struct files_struct,在這裏面fd的數組會複製一份,但是fd指向的struct file對於同一個文件還是隻有一份,這樣就做到了,兩個進程各有兩個fd指向同一個struct file的模式,兩個進程就可以通過各自的fd寫入和讀取同一個管道文件,實現跨進程通信了

由於管道只能一端寫入,另一端讀出,所以上面的這種模式會造成混亂,因爲父進程和子進程都可以寫入,也都可以讀出,通常的方法是父進程關閉讀取的fd,只保留寫入的fd,而子進程關閉寫入的fd,只保留讀取的fd,如果需要雙向通行,則應該創建兩個管道,如下所示:

一個典型的使用管道在父子進程之間的通信代碼如下:

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

int main(int argc, char *argv[])
{
  int fds[2];
  if (pipe(fds) == -1)
    perror("pipe error");

  pid_t pid;
  pid = fork();
  if (pid == -1)
    perror("fork error");

  if (pid == 0){
    close(fds[0]);
    char msg[] = "hello world";
    write(fds[1], msg, strlen(msg) + 1);
    close(fds[1]);
    exit(0);
  } else {
    close(fds[1]);
    char msg[128];
    read(fds[0], msg, 128);
    close(fds[0]);
    printf("message : %s\n", msg);
    return 0;
  }
}

到這裏僅僅解析了使用管道進行父子進程之間的通信,但是在shell裏面的不是這樣的。shell裏面運行A|B的時候,A進程和B進程都是shell創建出來的子進程,A和B之間不存在父子關係。不過有了上面父子進程之間的管道這個基礎,實現A和B之間的管道就方便多了。

首先從shell創建子進程A,然後在shell和A之間建立一個管道,其中shell保留讀取端,A進程保留寫入端,然後shell再創建子進程B,這又是一次fork,所以shell裏面保留的讀取端的fd也被複制到了子進程B裏面。這個時候相當於shell和B都保留讀取端,只要shell主動關閉讀取端就變成了一個管道,寫入端在A進程,讀取端在B進程,如下圖所示:

17. 接下來要做的事情就是,將這個管道的兩端和輸入輸出關聯起來。這就要用到dup2系統調用了,如下所示:

int dup2(int oldfd, int newfd);

這個系統調用將老的文件描述符賦值給新的文件描述符,讓newfd的值和oldfd一樣。在files_struct裏面有這樣一個表,下標是fd,內容指向一個打開的文件struct file,如下所示:

struct files_struct {
  struct file __rcu * fd_array[NR_OPEN_DEFAULT];
}

在這個表裏面前三項是定下來的,其中第零項STDIN_FILENO表示標準輸入,第一項STDOUT_FILENO表示標準輸出,第三項STDERR_FILENO表示錯誤輸出。在A進程中,寫入端可以做這樣的操作:dup2(fd[1],STDOUT_FILENO),將STDOUT_FILENO(即第一項)不再指向標準輸出,而是指向創建的管道文件,那麼以後往標準輸出寫入的任何東西,都會寫入管道文件。

在B進程中,讀取端可以做這樣的操作:dup2(fd[0],STDIN_FILENO),將STDIN_FILENO即第零項不再指向標準輸入,而是指向創建的管道文件,那麼以後從標準輸入讀取的任何東西,都來自於管道文件。至此纔將A|B的功能完成,如下圖所示:

18. 爲了模擬A|B的情況,可以將前面的那一段代碼,進一步修改成爲下面這樣:

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

int main(int argc, char *argv[])
{
  int fds[2];
  if (pipe(fds) == -1)
    perror("pipe error");

  pid_t pid;
  pid = fork();
  if (pid == -1)
    perror("fork error");

  if (pid == 0){
    dup2(fds[1], STDOUT_FILENO);
    close(fds[1]);
    close(fds[0]);
    execlp("ps", "ps", "-ef", NULL);
  } else {
    dup2(fds[0], STDIN_FILENO);
    close(fds[0]);
    close(fds[1]);
    execlp("grep", "grep", "systemd", NULL);
  }
  
  return 0;
}

接下來看命名管道。在講命令的時候提過,命名管道需要事先通過命令mkfifo進行創建。如果是通過代碼創建命名管道,也有一個函數但不是一個系統調用,而是Glibc提供的函數,它的定義如下:

int
mkfifo (const char *path, mode_t mode)
{
  dev_t dev = 0;
  return __xmknod (_MKNOD_VER, path, mode | S_IFIFO, &dev);
}

int
__xmknod (int vers, const char *path, mode_t mode, dev_t *dev)
{
  unsigned long long int k_dev;
......
  /* We must convert the value to dev_t type used by the kernel.  */
  k_dev = (*dev) & ((1ULL << 32) - 1);
......
  return INLINE_SYSCALL (mknodat, 4, AT_FDCWD, path, mode,
                         (unsigned int) k_dev);
}

Glibc的mkfif 函數會調用mknodat系統調用,記得之前提到字符設備時,創建一個字符設備的也是調用的mknod。這裏命名管道也是一個設備,因而也用mknod,如下所示:

SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode, unsigned, dev)
{
  struct dentry *dentry;
  struct path path;
  unsigned int lookup_flags = 0;
......
retry:
  dentry = user_path_create(dfd, filename, &path, lookup_flags);
......
  switch (mode & S_IFMT) {
......
    case S_IFIFO: case S_IFSOCK:
      error = vfs_mknod(path.dentry->d_inode,dentry,mode,0);
      break;
  }
......
}

對於mknod的解析在字符設備部分已經解析過了,即先是通過user_path_create對於這個管道文件創建一個dentry,然後因爲是S_IFIFO所以調用vfs_mknod。由於這個管道文件是創建在一個普通文件系統上的,假設是在ext4文件上,於是vfs_mknod會調用ext4_dir_inode_operations的mknod,即會調用ext4_mknod,如下所示:

const struct inode_operations ext4_dir_inode_operations = {
......
  .mknod    = ext4_mknod,
......
};

static int ext4_mknod(struct inode *dir, struct dentry *dentry,
          umode_t mode, dev_t rdev)
{
  handle_t *handle;
  struct inode *inode;
......
  inode = ext4_new_inode_start_handle(dir, mode, &dentry->d_name, 0,
              NULL, EXT4_HT_DIR, credits);
  handle = ext4_journal_current_handle();
  if (!IS_ERR(inode)) {
    init_special_inode(inode, inode->i_mode, rdev);
    inode->i_op = &ext4_special_inode_operations;
    err = ext4_add_nondir(handle, dentry, inode);
    if (!err && IS_DIRSYNC(dir))
      ext4_handle_sync(handle);
  }
  if (handle)
    ext4_journal_stop(handle);
......
}

#define ext4_new_inode_start_handle(dir, mode, qstr, goal, owner, \
            type, nblocks)        \
  __ext4_new_inode(NULL, (dir), (mode), (qstr), (goal), (owner), \
       0, (type), __LINE__, (nblocks))

void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
  inode->i_mode = mode;
  if (S_ISCHR(mode)) {
    inode->i_fop = &def_chr_fops;
    inode->i_rdev = rdev;
  } else if (S_ISBLK(mode)) {
    inode->i_fop = &def_blk_fops;
    inode->i_rdev = rdev;
  } else if (S_ISFIFO(mode))
    inode->i_fop = &pipefifo_fops;
  else if (S_ISSOCK(mode))
    ;  /* leave it no_open_fops */
  else
......
}

在ext4_mknod中,ext4_new_inode_start_handle會調用__ext4_new_inode,在ext4文件系統上真的創建一個文件,但是會調用init_special_inode,創建一個內存中特殊的inode,這個函數在字符設備文件中也遇到過,只不過當時inode的i_fop指向的是def_chr_fops,這次換成管道文件了,inode的i_fop變成指向 pipefifo_fops,這一點和匿名管道是一樣的。

這樣管道文件就創建完畢了。接下來要打開這個管道文件,還是會調用文件系統的open函數,還是沿着文件系統的調用方式,一路調用到pipefifo_fops的open函數,也就是fifo_open,如下所示:

static int fifo_open(struct inode *inode, struct file *filp)
{
  struct pipe_inode_info *pipe;
  bool is_pipe = inode->i_sb->s_magic == PIPEFS_MAGIC;
  int ret;
  filp->f_version = 0;

  if (inode->i_pipe) {
    pipe = inode->i_pipe;
    pipe->files++;
  } else {
    pipe = alloc_pipe_info();
    pipe->files = 1;
    inode->i_pipe = pipe;
    spin_unlock(&inode->i_lock);
  }
  filp->private_data = pipe;
  filp->f_mode &= (FMODE_READ | FMODE_WRITE);

  switch (filp->f_mode) {
  case FMODE_READ:
    pipe->r_counter++;
    if (pipe->readers++ == 0)
      wake_up_partner(pipe);
    if (!is_pipe && !pipe->writers) {
      if ((filp->f_flags & O_NONBLOCK)) {
      filp->f_version = pipe->w_counter;
      } else {
        if (wait_for_partner(pipe, &pipe->w_counter))
          goto err_rd;
      }
    }
    break;
  case FMODE_WRITE:
    pipe->w_counter++;
    if (!pipe->writers++)
      wake_up_partner(pipe);
    if (!is_pipe && !pipe->readers) {
      if (wait_for_partner(pipe, &pipe->r_counter))
        goto err_wr;
    }
    break;
  case FMODE_READ | FMODE_WRITE:
    pipe->readers++;
    pipe->writers++;
    pipe->r_counter++;
    pipe->w_counter++;
    if (pipe->readers == 1 || pipe->writers == 1)
      wake_up_partner(pipe);
    break;
......
  }
......
}

在fifo_open裏面創建pipe_inode_info,這一點和匿名管道也是一樣的。這個結構裏面有個成員是struct pipe_buffer *bufs。可以知道所謂的命名管道,其實也是內核裏面的一串緩存。接下來對於命名管道的寫入,還是會調用pipefifo_fop 的pipe_write函數,向pipe_buffer裏面寫入數據。對於命名管道的讀入,還是會調用pipefifo_fops的 pipe_read,也就是從 pipe_buffer 裏面讀取數據。

19. 無論是匿名管道還是命名管道,在內核都是一個文件,只要是文件就要有一個inode。這裏又用到了特殊inode,字符設備、塊設備其實都是這種特殊inode。在這種特殊的inode裏面,file_operations指向管道特殊的pipefifo_fops,這個inode對應內存裏面的緩存,當用文件的open函數打開這個管道設備文件的時候,會調用pipefifo_fops裏面的方法創建struct file結構,它的inode指向特殊的inode,也對應內存裏面的緩存,file_operations也指向管道特殊的pipefifo_fops。寫入一個pipe就是從struct file結構找到緩存寫入,讀取一個pipe就是從struct file結構找到緩存讀出,如下圖所示:

四、IPC

20. 接下來詳細講講進程之間共享內存的機制。有了這個機制,兩個進程可以像訪問自己內存中的變量一樣,訪問共享內存的變量。但是同時問題也來了,當兩個進程共享內存了,就會存在同時讀寫的問題,就需要對於共享的內存進行保護,就需要信號量這樣的同步協調機制。共享內存和信號量也是System V系列的進程間通信機制,所以很多地方和消息隊列有點像。爲了將共享內存和信號量結合起來使用,這裏定義了一個share.h頭文件,裏面放了一些共享內存和信號量在每個進程都需要的函數,如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <string.h>

#define MAX_NUM 128

struct shm_data {
  int data[MAX_NUM];
  int datalength;
};

union semun {
  int val; 
  struct semid_ds *buf; 
  unsigned short int *array; 
  struct seminfo *__buf; 
}; 

int get_shmid(){
  int shmid;
  key_t key;
  
  if((key = ftok("/root/sharememory/sharememorykey", 1024)) < 0){
      perror("ftok error");
          return -1;
  }
  
  shmid = shmget(key, sizeof(struct shm_data), IPC_CREAT|0777);
  return shmid;
}

int get_semaphoreid(){
  int semid;
  key_t key;
  
  if((key = ftok("/root/sharememory/semaphorekey", 1024)) < 0){
      perror("ftok error");
          return -1;
  }
  
  semid = semget(key, 1, IPC_CREAT|0777);
  return semid;
}

int semaphore_init (int semid) {
  union semun argument; 
  unsigned short values[1]; 
  values[0] = 1; 
  argument.array = values; 
  return semctl (semid, 0, SETALL, argument); 
}

int semaphore_p (int semid) {
  struct sembuf operations[1]; 
  operations[0].sem_num = 0; 
  operations[0].sem_op = -1; 
  operations[0].sem_flg = SEM_UNDO; 
  return semop (semid, operations, 1); 
}

int semaphore_v (int semid) {
  struct sembuf operations[1]; 
  operations[0].sem_num = 0; 
  operations[0].sem_op = 1; 
  operations[0].sem_flg = SEM_UNDO; 
  return semop (semid, operations, 1); 
}

先來看裏面對於共享內存的操作。首先創建之前要有一個key來唯一標識這個共享內存。這個key可以根據文件系統上一個文件的inode隨機生成。然後需要創建一個共享內存,就像創建一個消息隊列差不多,都是使用xxxget來創建。其中創建共享內存使用的是下面這個函數:

int shmget(key_t key, size_t size, int shmflag);

其中,key就是前面生成的那個key,shmflag如果爲IPC_CREAT就表示新創建,還可以指定讀寫權限0777。對於共享內存需要指定一個大小size,這個一般要申請多大呢?一個最佳實踐是,將多個進程需要共享的數據放在一個struct裏,然後這裏的size就應該是這個struct的大小。這樣每一個進程得到這塊內存後,只要強制將類型轉換爲這個struct類型,就能夠訪問裏面的共享數據了。

在這裏定義了一個struct shm_data結構。這裏面有兩個成員,一個是整型的數組,一個是數組中元素的個數。生成了共享內存以後,接下來就是將這個共享內存映射到進程的虛擬地址空間中,使用下面這個函數來進行操作:

void *shmat(int  shm_id, const  void *addr, int shmflg);

這裏面的shm_id,就是上面創建的共享內存的id,addr就是指定映射在某個地方。如果不指定,則內核會自動選擇一個地址作爲返回值返回。得到了返回地址以後,需要將指針強制類型轉換爲struct shm_data結構,就可以使用這個指針設置data和datalength了。當共享內存使用完畢,可以通過shmdt解除它到虛擬內存的映射,如下所示:

int shmdt(const  void *shmaddr);

21. 接下來看信號量。信號量以集合的形式存在的,首先創建之前同樣需要有一個key,來唯一標識這個信號量集合。這個key同樣可以根據文件系統上一個文件的inode隨機生成。然後需要創建一個信號量集合,同樣也是使用xxxget來創建,其中創建信號量集合使用的是下面這個函數:

int semget(key_t key, int nsems, int semflg);

這裏面的key就是前面生成的那個key,shmflag如果爲IPC_CREAT就表示新創建,也可以指定讀寫權限0777即所有人可操作。這裏nsems表示這個信號量集合裏面有幾個信號量,最簡單的情況下設置爲1。信號量往往代表某種資源的數量,如果用信號量做互斥,那往往將信號量設置爲1,這就是上面代碼中semaphore_init函數的作用,這裏面調用semctl函數,將這個信號量集合中的第0個信號量,即唯一的這個信號量設置爲1。

對於信號量,往往要定義兩種操作,P操作和V操作,對應上面代碼中semaphore_p函數和semaphore_v函數semaphore_p會調用semop函數將信號量的值減一,表示申請佔用一個資源,發現當前沒有資源的時候進入等待。semaphore_v會調用semop函數將信號量的值加一,表示釋放一個資源,釋放之後就允許等待中的其他進程佔用這個資源。

可以用這個信號量來保護共享內存中的struct shm_data,使得同時只有一個進程可以操作這個結構。這裏構建一個場景分爲producer.c和consumer.c,其中producer即生產者,負責往struct shm_data塞入數據,而consumer.c負責處理struct shm_data中的數據。下面來看producer.c的代碼:

#include "share.h"

int main() {
  void *shm = NULL;
  struct shm_data *shared = NULL;
  int shmid = get_shmid();
  int semid = get_semaphoreid();
  int i;
  
  shm = shmat(shmid, (void*)0, 0);
  if(shm == (void*)-1){
    exit(0);
  }
  shared = (struct shm_data*)shm;
  memset(shared, 0, sizeof(struct shm_data));
  semaphore_init(semid);
  while(1){
    semaphore_p(semid);
    if(shared->datalength > 0){
      semaphore_v(semid);
      sleep(1);
    } else {
      printf("how many integers to caculate : ");
      scanf("%d",&shared->datalength);
      if(shared->datalength > MAX_NUM){
        perror("too many integers.");
        shared->datalength = 0;
        semaphore_v(semid);
        exit(1);
      }
      for(i=0;i<shared->datalength;i++){
        printf("Input the %d integer : ", i);
        scanf("%d",&shared->data[i]);
      }
      semaphore_v(semid);
    }
  }
}

在這裏面,get_shmid創建了共享內存,get_semaphoreid創建了信號量集合,然後shmat將共享內存映射到了虛擬地址空間的shm指針指向的位置,然後通過強制類型轉換,shared的指針指向放在共享內存裏面的struct shm_data結構,然後初始化爲0。semaphore_init將信號量進行了初始化。

接着producer進入了一個無限循環。在這個循環裏面先通過semaphore_p申請訪問共享內存的權利,如果發現datalength大於零,說明共享內存裏面的數據沒有被處理過,於是semaphore_v釋放訪問權利先睡一會兒,睡醒了再看。如果發現datalength等於0,說明共享內存裏面的數據被處理完了,於是開始往裏面放數據。讓用戶輸入多少個數,然後每個數是什麼,都放在struct shm_data結構中,然後semaphore_v釋放訪問權利,等待其他的進程將這些數據拿去處理。再來看consumer的代碼,如下所示:

#include "share.h"

int main() {
  void *shm = NULL;
  struct shm_data *shared = NULL;
  int shmid = get_shmid();
  int semid = get_semaphoreid();
  int i;
  
  shm = shmat(shmid, (void*)0, 0);
  if(shm == (void*)-1){
    exit(0);
  }
  shared = (struct shm_data*)shm;
  while(1){
    semaphore_p(semid);
    if(shared->datalength > 0){
      int sum = 0;
      for(i=0;i<shared->datalength-1;i++){
        printf("%d+",shared->data[i]);
        sum += shared->data[i];
      }
      printf("%d",shared->data[shared->datalength-1]);
      sum += shared->data[shared->datalength-1];
      printf("=%d\n",sum);
      memset(shared, 0, sizeof(struct shm_data));
      semaphore_v(semid);
    } else {
      semaphore_v(semid);
      printf("no tasks, waiting.\n");
      sleep(1);
    }
  }
}

在這裏面,get_shmid獲得producer創建的共享內存,get_semaphoreid獲得producer創建的信號量集合,然後shmat將共享內存映射到了虛擬地址空間的shm指針指向的位置,然後通過強制類型轉換,shared的指針指向放在共享內存裏面的struct shm_data結構。

接着consumer進入了一個無限循環,在這個循環裏面先通過semaphore_p申請訪問共享內存的權利,如果發現datalength等於0,就說明沒什麼活幹需要等待。如果發現datalength大於0就說明有活幹,於是將datalength個整型數字從data數組中取出來求和。最後將struct shm_data清空爲0表示任務處理完畢,通過semaphore_v釋放訪問共享內存的權利。

通過程序創建的共享內存和信號量集合,可以通過命令ipcs查看。當然也可以通過ipcrm進行刪除,如下所示:

# ipcs
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x00016988 32768      root       777        516        0             
------ Semaphore Arrays --------
key        semid      owner      perms      nsems     
0x00016989 32768      root       777        1

下面來運行一下producer和consumer,可以得到下面的結果:

# ./producer 
how many integers to caculate : 2
Input the 0 integer : 3
Input the 1 integer : 4
how many integers to caculate : 4
Input the 0 integer : 3
Input the 1 integer : 4
Input the 2 integer : 5
Input the 3 integer : 6
how many integers to caculate : 7
Input the 0 integer : 9
Input the 1 integer : 8
Input the 2 integer : 7
Input the 3 integer : 6
Input the 4 integer : 5
Input the 5 integer : 4
Input the 6 integer : 3

# ./consumer 
3+4=7
3+4+5+6=18
9+8+7+6+5+4+3=42

22. 來總結一下共享內存和信號量的配合機制,如下圖所示:

(1)無論是共享內存還是信號量,創建與初始化都遵循同樣流程,即通過ftok得到key,通過xxxget創建對象並生成id;

(2)生產者和消費者都通過shmat將共享內存映射到各自的內存空間,在不同的進程裏面映射的位置不同;

(3)爲了訪問共享內存,需要信號量進行保護,信號量需要通過semctl初始化爲某個值;

(4)接下來生產者和消費者要通過semop(-1)來競爭信號量,如果生產者搶到信號量則寫入,然後通過semop(+1)釋放信號量,如果消費者搶到信號量則讀出,然後通過semop(+1)釋放信號量;

(5)共享內存使用完畢,可以通過 shmdt 來解除映射。

23. 瞭解瞭如何使用共享內存和信號量集合之後,來解析一下內核裏面都做了什麼。簽名講消息隊列、共享內存、信號量機制的時候,其實能夠從中看到一些統一的規律:它們在使用之前都要生成key,然後通過key得到唯一的id,並且都是通過xxxget函數。在內核裏面,這三種進程間通信機制是使用統一的機制管理起來的,都叫ipcxxx。爲了維護這三種進程間通信進制,在內核裏面聲明瞭一個有三項的數組。通過下面這段代碼,來具體看一看:

struct ipc_namespace {
......
  struct ipc_ids  ids[3];
......
}

#define IPC_SEM_IDS  0
#define IPC_MSG_IDS  1
#define IPC_SHM_IDS  2

#define sem_ids(ns)  ((ns)->ids[IPC_SEM_IDS])
#define msg_ids(ns)  ((ns)->ids[IPC_MSG_IDS])
#define shm_ids(ns)  ((ns)->ids[IPC_SHM_IDS])

根據代碼中的定義,第0項用於信號量,第1項用於消息隊列,第2項用於共享內存,分別可以通過sem_ids、msg_ids、shm_ids來訪問。這段代碼裏面有ns全稱叫namespace,可能不容易理解,現在可以將它認爲是將一臺Linux服務器邏輯的隔離爲多臺Linux服務器的機制,它背後的原理需要在容器部分詳細講述,現在可以簡單的裝作沒有namespace,整個Linux在一個namespace下面,暫時認爲這些ids也是整個Linux只有一份。接下來看struct ipc_ids裏面保存了什麼,如下所示:

struct ipc_ids {
  int in_use;
  unsigned short seq;
  struct rw_semaphore rwsem;
  struct idr ipcs_idr;
  int next_id;
};

struct idr {
  struct radix_tree_root  idr_rt;
  unsigned int    idr_next;
};

首先in_use表示當前有多少個ipc;其次seq和next_id用於一起生成ipc唯一的id,因爲信號量、共享內存、消息隊列它們三個的id也不能重複;ipcs_idr是一棵基數樹,這裏又碰到它了,一旦涉及從一個整數查找一個對象,它都是最好的選擇。也就是說,對於sem_ids、msg_ids、shm_ids各有一棵基數樹。那這棵樹裏面究竟存放了什麼,能夠統一管理這三類ipc對象呢?通過下面這個函數ipc_obtain_object_idr可以看出端倪。這個函數根據id,在基數樹裏面找出來的是struct kern_ipc_perm:

struct kern_ipc_perm *ipc_obtain_object_idr(struct ipc_ids *ids, int id)
{
  struct kern_ipc_perm *out;
  int lid = ipcid_to_idx(id);
  out = idr_find(&ids->ipcs_idr, lid);
  return out;
}

如果看用於表示信號量、消息隊列、共享內存的結構,就會發現這三個結構的第一項都是struct kern_ipc_perm,如下所示:

struct sem_array {
  struct kern_ipc_perm  sem_perm;  /* permissions .. see ipc.h */
  time_t      sem_ctime;  /* create/last semctl() time */
  struct list_head  pending_alter;  /* pending operations */
                            /* that alter the array */
  struct list_head  pending_const;  /* pending complex operations */
            /* that do not alter semvals */
  struct list_head  list_id;  /* undo requests on this array */
  int      sem_nsems;  /* no. of semaphores in array */
  int      complex_count;  /* pending complex operations */
  unsigned int    use_global_lock;/* >0: global lock required */

  struct sem    sems[];
} __randomize_layout;

struct msg_queue {
  struct kern_ipc_perm q_perm;
  time_t q_stime;      /* last msgsnd time */
  time_t q_rtime;      /* last msgrcv time */
  time_t q_ctime;      /* last change time */
  unsigned long q_cbytes;    /* current number of bytes on queue */
  unsigned long q_qnum;    /* number of messages in queue */
  unsigned long q_qbytes;    /* max number of bytes on queue */
  pid_t q_lspid;      /* pid of last msgsnd */
  pid_t q_lrpid;      /* last receive pid */

  struct list_head q_messages;
  struct list_head q_receivers;
  struct list_head q_senders;
} __randomize_layout;

struct shmid_kernel /* private to the kernel */
{  
  struct kern_ipc_perm  shm_perm;
  struct file    *shm_file;
  unsigned long    shm_nattch;
  unsigned long    shm_segsz;
  time_t      shm_atim;
  time_t      shm_dtim;
  time_t      shm_ctim;
  pid_t      shm_cprid;
  pid_t      shm_lprid;
  struct user_struct  *mlock_user;

  /* The task created the shm object.  NULL if the task is dead. */
  struct task_struct  *shm_creator;
  struct list_head  shm_clist;  /* list by creator */
} __randomize_layout;

也就是說完全可以通過struct kern_ipc_perm的指針,通過進行強制類型轉換後得到整個結構。做這件事情的函數如下面三個所示:

static inline struct sem_array *sem_obtain_object(struct ipc_namespace *ns, int id)
{
  struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&sem_ids(ns), id);
  return container_of(ipcp, struct sem_array, sem_perm);
}

static inline struct msg_queue *msq_obtain_object(struct ipc_namespace *ns, int id)
{
  struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&msg_ids(ns), id);
  return container_of(ipcp, struct msg_queue, q_perm);
}

static inline struct shmid_kernel *shm_obtain_object(struct ipc_namespace *ns, int id)
{
  struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&shm_ids(ns), id);
  return container_of(ipcp, struct shmid_kernel, shm_perm);
}

通過這種機制,就可以將信號量、消息隊列、共享內存抽象爲ipc類型進行統一處理。這有點面向對象編程中抽象類和實現類的意思,C++中類的實現機制其實也是這麼幹的,如下圖所示:

24. 有了抽象類,接下來看共享內存和信號量的具體實現。首先來看創建共享內存的的系統調用,如下所示:

SYSCALL_DEFINE3(shmget, key_t, key, size_t, size, int, shmflg)
{
  struct ipc_namespace *ns;
  static const struct ipc_ops shm_ops = {
    .getnew = newseg,
    .associate = shm_security,
    .more_checks = shm_more_checks,
  };
  struct ipc_params shm_params;
  ns = current->nsproxy->ipc_ns;
  shm_params.key = key;
  shm_params.flg = shmflg;
  shm_params.u.size = size;
  return ipcget(ns, &shm_ids(ns), &shm_ops, &shm_params);
}

這裏面調用了抽象的ipcget、參數分別爲共享內存對應的shm_ids、對應的操作shm_ops以及對應的參數shm_params。如果key設置爲IPC_PRIVATE則永遠創建新的,如果不是的話就會調用ipcget_public。ipcget的具體代碼如下:

int ipcget(struct ipc_namespace *ns, struct ipc_ids *ids,
      const struct ipc_ops *ops, struct ipc_params *params)
{
  if (params->key == IPC_PRIVATE)
    return ipcget_new(ns, ids, ops, params);
  else
    return ipcget_public(ns, ids, ops, params);
}

static int ipcget_public(struct ipc_namespace *ns, struct ipc_ids *ids, const struct ipc_ops *ops, struct ipc_params *params)
{
  struct kern_ipc_perm *ipcp;
  int flg = params->flg;
  int err;
  ipcp = ipc_findkey(ids, params->key);
  if (ipcp == NULL) {
    if (!(flg & IPC_CREAT))
      err = -ENOENT;
    else
      err = ops->getnew(ns, params);
  } else {
    if (flg & IPC_CREAT && flg & IPC_EXCL)
      err = -EEXIST;
    else {
      err = 0;
      if (ops->more_checks)
        err = ops->more_checks(ipcp, params);
......
    }
  }
  return err;
}

在ipcget_public中會按照key,去查找struct kern_ipc_perm。如果沒有找到那就看是否設置了IPC_CREAT;如果設置了就創建一個新的。如果找到了就將對應的id返回。這裏重點看如何按照參數shm_ops,創建新的共享內存,會調用newseg,如下所示:

static int newseg(struct ipc_namespace *ns, struct ipc_params *params)
{
  key_t key = params->key;
  int shmflg = params->flg;
  size_t size = params->u.size;
  int error;
  struct shmid_kernel *shp;
  size_t numpages = (size + PAGE_SIZE - 1) >> PAGE_SHIFT;
  struct file *file;
  char name[13];
  vm_flags_t acctflag = 0;
......
  shp = kvmalloc(sizeof(*shp), GFP_KERNEL);
......
  shp->shm_perm.key = key;
  shp->shm_perm.mode = (shmflg & S_IRWXUGO);
  shp->mlock_user = NULL;

  shp->shm_perm.security = NULL;
......
  file = shmem_kernel_file_setup(name, size, acctflag);
......
  shp->shm_cprid = task_tgid_vnr(current);
  shp->shm_lprid = 0;
  shp->shm_atim = shp->shm_dtim = 0;
  shp->shm_ctim = get_seconds();
  shp->shm_segsz = size;
  shp->shm_nattch = 0;
  shp->shm_file = file;
  shp->shm_creator = current;

  error = ipc_addid(&shm_ids(ns), &shp->shm_perm, ns->shm_ctlmni);
......
  list_add(&shp->shm_clist, &current->sysvshm.shm_clist);
......
  file_inode(file)->i_ino = shp->shm_perm.id;

  ns->shm_tot += numpages;
  error = shp->shm_perm.id;
......
  return error;
}

newseg函數的第一步,是通過kvmalloc在直接映射區分配一個struct shmid_kernel結構。這個結構就是用來描述共享內存的。這個結構最開始就是上面說的struct kern_ipc_perm結構。接下來就是填充這個struct shmid_kernel結構,例如key、權限等。

newseg函數的第二步,共享內存需要和文件進行關聯。爲什麼要做這個呢?在之前內存映射的部分提過,虛擬地址空間可以和物理內存關聯,但是物理內存是某個進程獨享的。虛擬地址空間也可以映射到一個文件,文件是可以跨進程共享的。這裏的共享內存需要跨進程共享,也應該借鑑文件映射的思路,只不過不應該映射一個硬盤上的文件,而是映射到一個內存文件系統上的文件。mm/shmem.c裏面就定義了這樣一個基於內存的文件系統。這裏一定要注意區分shmem和shm的區別,前者是一個文件系統,後者是進程通信機制。

在系統初始化的時候,shmem_init註冊了shmem文件系統shmem_fs_type,並且掛載到shm_mnt下面,如下所示:

int __init shmem_init(void)
{
  int error;
  error = shmem_init_inodecache();
  error = register_filesystem(&shmem_fs_type);
  shm_mnt = kern_mount(&shmem_fs_type);
......
  return 0;
}

static struct file_system_type shmem_fs_type = {
  .owner    = THIS_MODULE,
  .name    = "tmpfs",
  .mount    = shmem_mount,
  .kill_sb  = kill_litter_super,
  .fs_flags  = FS_USERNS_MOUNT,
};

接下來,newseg函數會調用shmem_kernel_file_setup,其實就是在shmem文件系統裏面創建一個文件,如下所示:

/**
 * shmem_kernel_file_setup - get an unlinked file living in tmpfs which must be kernel internal.  
 * @name: name for dentry (to be seen in /proc/<pid>/maps
 * @size: size to be set for the file
 * @flags: VM_NORESERVE suppresses pre-accounting of the entire object size */
struct file *shmem_kernel_file_setup(const char *name, loff_t size, unsigned long flags)
{
  return __shmem_file_setup(name, size, flags, S_PRIVATE);
}

static struct file *__shmem_file_setup(const char *name, loff_t size,
               unsigned long flags, unsigned int i_flags)
{
  struct file *res;
  struct inode *inode;
  struct path path;
  struct super_block *sb;
  struct qstr this;
......
  this.name = name;
  this.len = strlen(name);
  this.hash = 0; /* will go */
  sb = shm_mnt->mnt_sb;
  path.mnt = mntget(shm_mnt);
  path.dentry = d_alloc_pseudo(sb, &this);
  d_set_d_op(path.dentry, &anon_ops);
......
  inode = shmem_get_inode(sb, NULL, S_IFREG | S_IRWXUGO, 0, flags);
  inode->i_flags |= i_flags;
  d_instantiate(path.dentry, inode);
  inode->i_size = size;
......
  res = alloc_file(&path, FMODE_WRITE | FMODE_READ,
      &shmem_file_operations);
  return res;
}

__shmem_file_setup會創建新的shmem文件對應的dentry和inode,並將它們兩個關聯起來,然後分配一個struct file結構來表示新的shmem文件,並且指向獨特的shmem_file_operations,它的實現如下所示:

static const struct file_operations shmem_file_operations = {
  .mmap    = shmem_mmap,
  .get_unmapped_area = shmem_get_unmapped_area,
#ifdef CONFIG_TMPFS
  .llseek    = shmem_file_llseek,
  .read_iter  = shmem_file_read_iter,
  .write_iter  = generic_file_write_iter,
  .fsync    = noop_fsync,
  .splice_read  = generic_file_splice_read,
  .splice_write  = iter_file_splice_write,
  .fallocate  = shmem_fallocate,
#endif
};

newseg函數的第三步,通過ipc_addid將新創建的struct shmid_kernel結構掛到shm_ids裏面的基數樹上,並返回相應的id,並且將struct shmid_kernel掛到當前進程的sysvshm隊列中。至此,共享內存的創建就完成了。

25. 從上面代碼解析中可以知道,共享內存的數據結構struct shmid_kernel,是通過它的成員struct file *shm_file來管理內存文件系統shmem上的內存文件的。無論這個共享內存是否被映射shm_file都是存在的。接下來要將共享內存映射到虛擬地址空間中調用的是shmat,對應的系統調用如下:

SYSCALL_DEFINE3(shmat, int, shmid, char __user *, shmaddr, int, shmflg)
{
    unsigned long ret;
    long err;
    err = do_shmat(shmid, shmaddr, shmflg, &ret, SHMLBA);
    force_successful_syscall_return();
    return (long)ret;
}

long do_shmat(int shmid, char __user *shmaddr, int shmflg,
        ulong *raddr, unsigned long shmlba)
{
  struct shmid_kernel *shp;
  unsigned long addr = (unsigned long)shmaddr;
  unsigned long size;
  struct file *file;
  int    err;
  unsigned long flags = MAP_SHARED;
  unsigned long prot;
  int acc_mode;
  struct ipc_namespace *ns;
  struct shm_file_data *sfd;
  struct path path;
  fmode_t f_mode;
  unsigned long populate = 0;
......
  prot = PROT_READ | PROT_WRITE;
  acc_mode = S_IRUGO | S_IWUGO;
  f_mode = FMODE_READ | FMODE_WRITE;
......
  ns = current->nsproxy->ipc_ns;
  shp = shm_obtain_object_check(ns, shmid);
......
  path = shp->shm_file->f_path;
  path_get(&path);
  shp->shm_nattch++;
  size = i_size_read(d_inode(path.dentry));
......
  sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);
......
  file = alloc_file(&path, f_mode,
        is_file_hugepages(shp->shm_file) ?
        &shm_file_operations_huge :
        &shm_file_operations);
......
  file->private_data = sfd;
  file->f_mapping = shp->shm_file->f_mapping;
  sfd->id = shp->shm_perm.id;
  sfd->ns = get_ipc_ns(ns);
  sfd->file = shp->shm_file;
  sfd->vm_ops = NULL;
......
  addr = do_mmap_pgoff(file, addr, size, prot, flags, 0, &populate, NULL);
  *raddr = addr;
  err = 0;
......
  return err;
}

在這個函數裏面shm_obtain_object_check會通過共享內存的id,在基數樹中找到對應的struct shmid_kernel結構,通過它找到shmem上的內存文件。接下來要分配一個struct shm_file_data來表示這個內存文件,將shmem中指向內存文件的shm_file賦值給struct shm_file_data中的file成員。然後創建了一個struct file,指向的也是shmem中的內存文件。

爲什麼要再創建一個呢?這兩個的功能不同,shmem中shm_file用於管理內存文件,是一箇中立的、獨立於任何一個進程的角色。而新創建的struct file是專門用於做內存映射的,就像一個硬盤上的文件要映射到虛擬地址空間中時,需要在vm_area_struct裏面有一個struct file *vm_file指向硬盤上的文件,現在變成內存文件了,但是這個結構還是不能少。

新創建的struct file的private_data指向struct shm_file_data,這樣內存映射那部分的數據結構,就能夠通過它來訪問內存文件了。新創建的struct file的file_operations也發生了變化,變成了shm_file_operations,如下所示:

static const struct file_operations shm_file_operations = {
  .mmap               = shm_mmap,
  .fsync              = shm_fsync,
  .release            = shm_release,
  .get_unmapped_area  = shm_get_unmapped_area,
  .llseek             = noop_llseek,
  .fallocate          = shm_fallocate,
};

接下來do_mmap_pgoff函數之前遇到過,原來映射硬盤上的文件時也是調用它。它會分配一個vm_area_struct指向虛擬地址空間中沒有分配的區域,它的vm_file指向這個內存文件,然後它會調用shm_file_operations的mmap函數即shm_mmap進行映射,如下所示:

static int shm_mmap(struct file *file, struct vm_area_struct *vma)
{
  struct shm_file_data *sfd = shm_file_data(file);
  int ret;
  ret = __shm_open(vma);
  ret = call_mmap(sfd->file, vma);
  sfd->vm_ops = vma->vm_ops;
  vma->vm_ops = &shm_vm_ops;
  return 0;
}

shm_mmap中調用了shm_file_data中file的mmap函數,這次調用的是shmem_file_operations的mmap,即shmem_mmap,如下所示:

static int shmem_mmap(struct file *file, struct vm_area_struct *vma)
{
  file_accessed(file);
  vma->vm_ops = &shmem_vm_ops;
  return 0;
}

這裏面vm_area_struct的vm_ops指向shmem_vm_ops。等從call_mmap中返回之後,shm_file_data的vm_ops指向了shmem_vm_ops,而vm_area_struct的vm_ops改爲指向shm_vm_ops,要注意區分這兩個不同的vm_ops。

26. 接下來看一下shm_vm_ops和shmem_vm_ops的定義,如下所示:

static const struct vm_operations_struct shm_vm_ops = {
  .open  = shm_open,  /* callback for a new vm-area open */
  .close  = shm_close,  /* callback for when the vm-area is released */
  .fault  = shm_fault,
};

static const struct vm_operations_struct shmem_vm_ops = {
  .fault    = shmem_fault,
  .map_pages  = filemap_map_pages,
};

它們裏面最關鍵的就是fault函數,即訪問虛擬內存的時候,訪問不到就會發生缺頁異常,先調用vm_area_struct的vm_ops,即shm_vm_ops的fault函數shm_fault。然後它會調用shm_file_data的vm_ops,即shmem_vm_ops的fault函數shmem_fault,如下所示:

static int shm_fault(struct vm_fault *vmf)
{
  struct file *file = vmf->vma->vm_file;
  struct shm_file_data *sfd = shm_file_data(file);
  return sfd->vm_ops->fault(vmf);
}

雖然基於內存的文件系統已經爲這個內存文件分配了inode,但是內存也卻是一點都沒分配,只有在發生缺頁異常的時候才進行分配。shmem_fault的實現如下所示:

static int shmem_fault(struct vm_fault *vmf)
{
  struct vm_area_struct *vma = vmf->vma;
  struct inode *inode = file_inode(vma->vm_file);
  gfp_t gfp = mapping_gfp_mask(inode->i_mapping);
......
  error = shmem_getpage_gfp(inode, vmf->pgoff, &vmf->page, sgp,
          gfp, vma, vmf, &ret);
......
}

/*
 * shmem_getpage_gfp - find page in cache, or get from swap, or allocate
 *
 * If we allocate a new one we do not mark it dirty. That's up to the
 * vm. If we swap it in we mark it dirty since we also free the swap
 * entry since a page cannot live in both the swap and page cache.
 *
 * fault_mm and fault_type are only supplied by shmem_fault:
 * otherwise they are NULL.
 */
static int shmem_getpage_gfp(struct inode *inode, pgoff_t index,
  struct page **pagep, enum sgp_type sgp, gfp_t gfp,
  struct vm_area_struct *vma, struct vm_fault *vmf, int *fault_type)
{
......
    page = shmem_alloc_and_acct_page(gfp, info, sbinfo,
          index, false);
......
}

shmem_fault會調用shmem_getpage_gfp在page cache和swap中找一個空閒頁,如果找不到就通過shmem_alloc_and_acct_page分配一個新的頁,它最終會調用內存管理系統的alloc_page_vma在物理內存中分配一個頁。至此共享內存才真的映射到了虛擬地址空間中,進程可以像訪問本地內存一樣訪問共享內存。

27. 來總結一下共享內存的創建和映射過程,如下圖所示:

(1)調用shmget創建共享內存。

(2)先通過ipc_findkey在基數樹中查找key對應的共享內存對象shmid_kernel是否已經被創建過,如果已經被創建就會被查詢出來,例如producer創建過在consumer中就會查詢出來。

(3)如果共享內存沒有被創建過,則調用shm_ops的newseg方法,創建一個共享內存對象shmid_kernel。例如在producer中就會新建。

(4)在shmem文件系統裏面創建一個文件,共享內存對象shmid_kernel指向這個文件,這個文件用struct file表示,姑且稱它爲file1。

(5)調用shmat,將共享內存映射到虛擬地址空間。

(6)shm_obtain_object_check先從基數樹裏面找到shmid_kernel對象。

(7)創建用於內存映射到文件的file和shm_file_data,這裏的struct file姑且稱爲file2。

(8)關聯內存區域vm_area_struct和用於內存映射到文件的file即file2,調用file2的mmap函數。

(9)file2的mmap函數shm_mmap,會調用file1的mmap函數shmem_mmap,設置shm_file_data和vm_area_struct的vm_ops。

(10)內存映射完畢之後,其實並沒有真的分配物理內存,當訪問內存的時候會觸發缺頁異常do_page_fault。

(11)vm_area_struct的vm_ops的shm_fault會調用shm_file_data的vm_ops的shmem_fault,在page cache中找一個空閒頁或者創建一個空閒頁。

28. 前面解析完了共享內存的內核機制後,來看信號量的內核機制。首先需要創建一個信號量,調用的是系統調用semget,代碼如下:

SYSCALL_DEFINE3(semget, key_t, key, int, nsems, int, semflg)
{
  struct ipc_namespace *ns;
  static const struct ipc_ops sem_ops = {
    .getnew = newary,
    .associate = sem_security,
    .more_checks = sem_more_checks,
  };
  struct ipc_params sem_params;
  ns = current->nsproxy->ipc_ns;
  sem_params.key = key;
  sem_params.flg = semflg;
  sem_params.u.nsems = nsems;
  return ipcget(ns, &sem_ids(ns), &sem_ops, &sem_params);
}

之前解析過了共享內存,再看信號量就順暢很多了。這裏同樣調用了抽象的ipcget,參數分別爲信號量對應的sem_ids、對應的操作sem_ops以及對應的參數sem_params。ipcget的代碼之前已經解析過了,如果key設置爲IPC_PRIVATE則永遠創建新的;如果不是的話就會調用ipcget_public。在ipcget_public中,會按照key去查找struct kern_ipc_perm,如果沒有找到,那就看看是否設置了IPC_CREAT;如果設置了就創建一個新的,如果找到了就將對應的id返回。這裏重點看,如何按照參數sem_ops創建新的信號量並調用newary,如下所示:

static int newary(struct ipc_namespace *ns, struct ipc_params *params)
{
  int retval;
  struct sem_array *sma;
  key_t key = params->key;
  int nsems = params->u.nsems;
  int semflg = params->flg;
  int i;
......
  sma = sem_alloc(nsems);
......
  sma->sem_perm.mode = (semflg & S_IRWXUGO);
  sma->sem_perm.key = key;
  sma->sem_perm.security = NULL;
......
  for (i = 0; i < nsems; i++) {
    INIT_LIST_HEAD(&sma->sems[i].pending_alter);
    INIT_LIST_HEAD(&sma->sems[i].pending_const);
    spin_lock_init(&sma->sems[i].lock);
  }
  sma->complex_count = 0;
  sma->use_global_lock = USE_GLOBAL_LOCK_HYSTERESIS;
  INIT_LIST_HEAD(&sma->pending_alter);
  INIT_LIST_HEAD(&sma->pending_const);
  INIT_LIST_HEAD(&sma->list_id);
  sma->sem_nsems = nsems;
  sma->sem_ctime = get_seconds();
  retval = ipc_addid(&sem_ids(ns), &sma->sem_perm, ns->sc_semmni);
......
  ns->used_sems += nsems;
......
  return sma->sem_perm.id;
}

newary函數的第一步,通過kvmalloc在直接映射區分配一個struct sem_array結構。這個結構是用來描述信號量的,這個結構最開始就是上面說的struct kern_ipc_perm結構。接下來就是填充這個struct sem_array結構,例如key、權限等。struct sem_array裏有多個信號量,放在struct sem sems[]數組裏面,在struct sem裏面有當前的信號量的數值semval,如下所示:

struct sem {
  int  semval;    /* current value */
  /*
   * PID of the process that last modified the semaphore. For
   * Linux, specifically these are:
   *  - semop
   *  - semctl, via SETVAL and SETALL.
   *  - at task exit when performing undo adjustments (see exit_sem).
   */
  int  sempid;
  spinlock_t  lock;  /* spinlock for fine-grained semtimedop */
  struct list_head pending_alter; /* pending single-sop operations that alter the semaphore */
  struct list_head pending_const; /* pending single-sop operations that do not alter the semaphore*/
  time_t  sem_otime;  /* candidate for sem_otime */
} ____cacheline_aligned_in_smp;

struct sem_array和 struct sem各有一個鏈表struct list_head pending_alter,分別表示對於整個信號量數組的修改和對於某個信號量的修改。newary函數的第二步就是初始化這些鏈表。newary函數的第三步,通過ipc_addid將新創建的struct sem_array結構,掛到sem_ids裏面的基數樹上,並返回相應的id。

29. 信號量創建的過程到此結束,接下來看如何通過semctl對信號量數組進行初始化,如下所示:

SYSCALL_DEFINE4(semctl, int, semid, int, semnum, int, cmd, unsigned long, arg)
{
  int version;
  struct ipc_namespace *ns;
  void __user *p = (void __user *)arg;
  ns = current->nsproxy->ipc_ns;
  switch (cmd) {
  case IPC_INFO:
  case SEM_INFO:
  case IPC_STAT:
  case SEM_STAT:
    return semctl_nolock(ns, semid, cmd, version, p);
  case GETALL:
  case GETVAL:
  case GETPID:
  case GETNCNT:
  case GETZCNT:
  case SETALL:
    return semctl_main(ns, semid, semnum, cmd, p);
  case SETVAL:
    return semctl_setval(ns, semid, semnum, arg);
  case IPC_RMID:
  case IPC_SET:
    return semctl_down(ns, semid, cmd, version, p);
  default:
    return -EINVAL;
  }
}

這裏重點看SETALL操作調用的semctl_main函數,以及SETVAL操作調用的semctl_setval函數。對於SETALL操作來講,傳進來的參數爲union semun裏面的unsigned short *array,會設置整個信號量集合,如下所示:

static int semctl_main(struct ipc_namespace *ns, int semid, int semnum,
    int cmd, void __user *p)
{
  struct sem_array *sma;
  struct sem *curr;
  int err, nsems;
  ushort fast_sem_io[SEMMSL_FAST];
  ushort *sem_io = fast_sem_io;
  DEFINE_WAKE_Q(wake_q);
  sma = sem_obtain_object_check(ns, semid);
  nsems = sma->sem_nsems;
......
  switch (cmd) {
......
  case SETALL:
  {
    int i;
    struct sem_undo *un;
......
    if (copy_from_user(sem_io, p, nsems*sizeof(ushort))) {
......
    }
......
    for (i = 0; i < nsems; i++) {
      sma->sems[i].semval = sem_io[i];
      sma->sems[i].sempid = task_tgid_vnr(current);
    }
......
    sma->sem_ctime = get_seconds();
    /* maybe some queued-up processes were waiting for this */
    do_smart_update(sma, NULL, 0, 0, &wake_q);
    err = 0;
    goto out_unlock;
  }
  }
......
    wake_up_q(&wake_q);
......
}

在semctl_setval函數中,先是通過sem_obtain_object_check根據信號量集合的id,在基數樹裏面找到struct sem_array對象,對於SETVAL操作,直接根據參數中的val設置semval,以及修改這個信號量值的pid。

30. 至此,信號量數組初始化完畢。接下來看P操作和V操作,無論是P操作還是V操作都是調用semop系統調用,如下所示:

SYSCALL_DEFINE3(semop, int, semid, struct sembuf __user *, tsops,
    unsigned, nsops)
{
  return sys_semtimedop(semid, tsops, nsops, NULL);
}

SYSCALL_DEFINE4(semtimedop, int, semid, struct sembuf __user *, tsops,
    unsigned, nsops, const struct timespec __user *, timeout)
{
  int error = -EINVAL;
  struct sem_array *sma;
  struct sembuf fast_sops[SEMOPM_FAST];
  struct sembuf *sops = fast_sops, *sop;
  struct sem_undo *un;
  int max, locknum;
  bool undos = false, alter = false, dupsop = false;
  struct sem_queue queue;
  unsigned long dup = 0, jiffies_left = 0;
  struct ipc_namespace *ns;

  ns = current->nsproxy->ipc_ns;
......
  if (copy_from_user(sops, tsops, nsops * sizeof(*tsops))) {
    error =  -EFAULT;
    goto out_free;
  }

  if (timeout) {
    struct timespec _timeout;
    if (copy_from_user(&_timeout, timeout, sizeof(*timeout))) {
    }
    jiffies_left = timespec_to_jiffies(&_timeout);
  }
......
  /* On success, find_alloc_undo takes the rcu_read_lock */
  un = find_alloc_undo(ns, semid);
......
  sma = sem_obtain_object_check(ns, semid);
......
  queue.sops = sops;
  queue.nsops = nsops;
  queue.undo = un;
  queue.pid = task_tgid_vnr(current);
  queue.alter = alter;
  queue.dupsop = dupsop;

  error = perform_atomic_semop(sma, &queue);
  if (error == 0) { /* non-blocking succesfull path */
    DEFINE_WAKE_Q(wake_q);
......
    do_smart_update(sma, sops, nsops, 1, &wake_q);
......
    wake_up_q(&wake_q);
    goto out_free;
  }
  /*
   * We need to sleep on this operation, so we put the current
   * task into the pending queue and go to sleep.
   */
  if (nsops == 1) {
    struct sem *curr;
    curr = &sma->sems[sops->sem_num];
......
    list_add_tail(&queue.list,
            &curr->pending_alter);
......
  } else {
......
    list_add_tail(&queue.list, &sma->pending_alter);
......
  }

  do {
    queue.status = -EINTR;
    queue.sleeper = current;

    __set_current_state(TASK_INTERRUPTIBLE);
    if (timeout)
      jiffies_left = schedule_timeout(jiffies_left);
    else
      schedule();
......
    /*
     * If an interrupt occurred we have to clean up the queue.
     */
    if (timeout && jiffies_left == 0)
      error = -EAGAIN;
  } while (error == -EINTR && !signal_pending(current)); /* spurious */
......
}

semop會調用semtimedop,這是一個非常複雜的函數。semtimedop做的第一件事情就是將用戶的參數,例如對於信號量的操作struct sembuf,拷貝到內核裏面來。另外如果是P操作,很可能讓進程進入等待狀態,要爲這個等待狀態設置一個超時即timeout參數,會把它變成時鐘的滴答數目。

semtimedop做的第二件事情,是通過sem_obtain_object_check根據信號量集合的id獲得struct sem_array,然後創建一個struct sem_queue表示當前的信號量操作。爲什麼叫queue呢?因爲這個操作可能馬上就能完成,也可能因爲無法獲取信號量不能完成,不能完成就只好排列到隊列上,等待信號量滿足條件的時候。semtimedop會調用perform_atomic_semop再實施信號量操作,如下所示:

static int perform_atomic_semop(struct sem_array *sma, struct sem_queue *q)
{
  int result, sem_op, nsops;
  struct sembuf *sop;
  struct sem *curr;
  struct sembuf *sops;
  struct sem_undo *un;

  sops = q->sops;
  nsops = q->nsops;
  un = q->undo;

  for (sop = sops; sop < sops + nsops; sop++) {
    curr = &sma->sems[sop->sem_num];
    sem_op = sop->sem_op;
    result = curr->semval;
......
    result += sem_op;
    if (result < 0)
      goto would_block;
......
    if (sop->sem_flg & SEM_UNDO) {
      int undo = un->semadj[sop->sem_num] - sem_op;
.....
    }
  }

  for (sop = sops; sop < sops + nsops; sop++) {
    curr = &sma->sems[sop->sem_num];
    sem_op = sop->sem_op;
    result = curr->semval;

    if (sop->sem_flg & SEM_UNDO) {
      int undo = un->semadj[sop->sem_num] - sem_op;
      un->semadj[sop->sem_num] = undo;
    }
    curr->semval += sem_op;
    curr->sempid = q->pid;
  }
  return 0;
would_block:
  q->blocking = sop;
  return sop->sem_flg & IPC_NOWAIT ? -EAGAIN : 1;
}

在perform_atomic_semop函數中,對於所有信號量操作都進行兩次循環。在第一次循環中,如果發現計算出的result小於0,則說明必須等待,於是跳到would_block中,設置q->blocking = sop表示這個queue是block在這個操作上,然後如果需要等待則返回1。如果第一次循環中發現無需等待,則第二個循環實施所有的信號量操作,將信號量的值設置爲新的值,並且返回0。

31. 接下來回到semtimedop,來看它乾的第三件事情,就是如果需要等待應該怎麼辦?如果需要等待,則要區分剛纔的對於信號量的操作,是對一個信號量的還是對於整個信號量集合的。如果是對於一個信號量的,那就將queue掛到這個信號量的pending_alter中;如果是對於整個信號量集合的,那就將queue掛到整個信號量集合的pending_alter中。

接下來的do-while循環就是要開始等待了。如果等待沒有時間限制,則調用schedule讓出 CPU;如果等待有時間限制,則調用schedule_timeout讓出CPU,過一段時間還回來。當回來的時候判斷是否等待超時,如果沒有等待超時則進入下一輪循環再次等待,如果超時則退出循環,返回錯誤。在讓出CPU時設置進程的狀態爲TASK_INTERRUPTIBLE,並且循環的結束會通過signal_pending查看是否收到過信號,這說明這個等待信號量的進程是可以被信號中斷的,即一個等待信號量的進程是可以通過kill殺掉的

再來看semtimedop要做的第四件事情,如果不需要等待應該怎麼辦?如果不需要等待,就說明對於信號量的操作完成了,也改變了信號量的值。接下就是一個標準流程。我們過DEFINE_WAKE_Q(wake_q)聲明一個wake_q,調用do_smart_update,看這次對於信號量的值的改變,可以影響並可以激活等待隊列中的哪些struct sem_queue,然後把它們都放在wake_q裏面,調用wake_up_q喚醒這些進程。

其實,所有對於信號量的值的修改都會涉及這三個操作,如果回過頭去仔細看SETALL和SETVAL操作,在設置完畢信號量之後,也是這三個操作。來看do_smart_update是如何實現的,它會調用update_queue,如下所示:

static int update_queue(struct sem_array *sma, int semnum, struct wake_q_head *wake_q)
{
  struct sem_queue *q, *tmp;
  struct list_head *pending_list;
  int semop_completed = 0;

  if (semnum == -1)
    pending_list = &sma->pending_alter;
  else
    pending_list = &sma->sems[semnum].pending_alter;

again:
  list_for_each_entry_safe(q, tmp, pending_list, list) {
    int error, restart;
......
    error = perform_atomic_semop(sma, q);

    /* Does q->sleeper still need to sleep? */
    if (error > 0)
      continue;

    unlink_queue(sma, q);
......
    wake_up_sem_queue_prepare(q, error, wake_q);
......
  }
  return semop_completed;
}

static inline void wake_up_sem_queue_prepare(struct sem_queue *q, int error,
               struct wake_q_head *wake_q)
{
  wake_q_add(wake_q, q->sleeper);
......
}

update_queue會依次循環整個信號量集合的等待隊列pending_alter,或者某個信號量的等待隊列,試圖在信號量的值變了的情況下,再次嘗試perform_atomic_semop進行信號量操作。如果不成功則嘗試隊列中的下一個;如果嘗試成功,則調用unlink_queue從隊列上取下來,然後調用wake_up_sem_queue_prepare將q->sleeper加到wake_q上去。q->sleeper是一個task_struct,是等待在這個信號量操作上的進程。接下來wake_up_q就依次喚醒wake_q上的所有task_struct,調用的是在進程調度部分提過的wake_up_process方法,如下所示:

void wake_up_q(struct wake_q_head *head)
{
  struct wake_q_node *node = head->first;

  while (node != WAKE_Q_TAIL) {
    struct task_struct *task;

    task = container_of(node, struct task_struct, wake_q);

    node = node->next;
    task->wake_q.next = NULL;

    wake_up_process(task);
    put_task_struct(task);
  }
}

至此,對於信號量的主流操作都解析完畢了。

32. 信號量是一個整個Linux可見的全局資源,好處是可以跨進程通信,壞處就是如果一個進程通過P操作拿到了一個信號量,但是不幸異常退出了,如果沒有來得及歸還這個信號量,可能所有其他的進程都阻塞了。那怎麼辦呢?Linux有一種機制叫SEM_UNDO,也即每一個semop操作都會保存一個反向struct sem_undo操作,當因爲某個進程異常退出的時候,這個進程做的所有的操作都會回退,從而保證其他進程可以正常工作。前面寫的程序裏面的semaphore_p函數和semaphore_v函數,都把sem_flg設置爲SEM_UNDO就是這個作用。等待隊列上的每一個struct sem_queue都有一個struct sem_undo,以此來表示這次操作的反向操作,如下所示:

struct sem_queue {
  struct list_head  list;       /* queue of pending operations */
  struct task_struct  *sleeper; /* this process */
  struct sem_undo    *undo;     /* undo structure */
  int      pid;                 /* process id of requesting process */
  int      status;              /* completion status of operation */
  struct sembuf    *sops;       /* array of pending operations */
  struct sembuf    *blocking;   /* the operation that blocked */
  int      nsops;               /* number of operations */
  bool     alter;               /* does *sops alter the array? */
  bool     dupsop;              /* sops on more than one sem_num */
};

在進程的task_struct裏面對於信號量有一個成員struct sysv_sem,裏面是一個struct sem_undo_list,將這個進程所有的semop所帶來的undo操作都串起來,如下所示:

struct task_struct {
......
struct sysv_sem      sysvsem;
......
}

struct sysv_sem {
  struct sem_undo_list *undo_list;
};

struct sem_undo {
  struct list_head  list_proc;  /* per-process list: *
             * all undos from one process
             * rcu protected */
  struct rcu_head    rcu;    /* rcu struct for sem_undo */
  struct sem_undo_list  *ulp;    /* back ptr to sem_undo_list */
  struct list_head  list_id;  /* per semaphore array list:
             * all undos for one array */
  int      semid;    /* semaphore set identifier */
  short      *semadj;  /* array of adjustments */
            /* one per semaphore */
};

struct sem_undo_list {
  atomic_t    refcnt;
  spinlock_t    lock;
  struct list_head  list_proc;
};

33. 爲了更清楚地理解struct sem_undo的原理,這裏舉一個例子。假設創建了兩個信號量集合,一個叫semaphore1,它包含三個信號量,初始化值爲3,另一個叫semaphore2,包含4個信號量,初始化值都爲4。初始化時候的信號量以及undo結構裏面的值如圖中(1)標號所示:

首先來看進程1,調用semop將semaphore1的三個信號量的值,分別加1、加2和減3,從而信號量的值變爲4、5、0。於是在semaphore1和進程1鏈表交匯的undo結構裏面填寫-1、-2、+3,是semop操作的反向操作,如圖中(2)標號所示。

然後來看進程2,調用semop將semaphore1的三個信號量的值,分別減3、加2和加1,從而信號量的值變爲1、7、1。於是在semaphore1和進程2鏈表交匯的undo結構裏面,填寫+3、-2、-1,是semop操作的反向操作,如圖中(3)標號所示。

然後接着看進程2,調用semop將semaphore2的四個信號量的值分別減3、加1、加4和減1,從而信號量的值變爲1、5、8、3。於是在semaphore2和進程2鏈表交匯的undo結構裏面,填寫+3、-1、-4、+1,是semop操作的反向操作,如圖中(4)標號所示。

然後再來看進程1,調用semop將semaphore2的四個信號量的值,分別減1、減4、減5 和加2,從而信號量的值變爲0、1、3、5。於是在semaphore2和進程1鏈表交匯的undo結構裏面填寫+1、+4、+5、-2,是semop操作的反向操作,如圖中(5)標號所示。

從這個例子可以看出,無論哪個進程異常退出,只要將undo結構裏面的值加回當前信號量的值,就能夠得到正確的信號量的值,不會因爲一個進程退出,導致信號量的值處於不一致的狀態

34. 信號量的機制也很複雜,對着下面這個圖總結一下:

(1)調用semget創建信號量集合。

(2)ipc_findkey會在基數樹中,根據key查找信號量集合sem_array對象。如果已經被創建就會被查詢出來,例如producer被創建過,在consumer中就會查詢出來。

(3)如果信號量集合沒有被創建過,則調用sem_ops的newary方法,創建一個信號量集合對象sem_array,例如在producer中就會新建。

(4)調用semctl(SETALL)初始化信號量。

(5)sem_obtain_object_check先從基數樹裏面找到sem_array對象。

(6)根據用戶指定的信號量數組,初始化信號量集合,即初始化sem_array對象的struct sem sems[]成員。

(7)調用semop操作信號量。

(8)創建信號量操作結構sem_queue,放入隊列。

(9)創建undo結構放入鏈表。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章