linux 操作系統:進程間通信方式總結

一. 爲什麼需要進程間通信

1). 數據傳輸

一個進程需要將它的數據發送給另一個進程。

2). 資源共享

多個進程之間共享同樣的資源。

3). 通知事件

一個進程需要向另一個或一組進程發送消息,通知它們發生了某種事件。

4). 進程控制

有些進程希望完全控制另一個進程的執行 (如 Debug 進程),該控制進程希望能夠攔截另一個進程的所有操作,並能夠及時知道它的狀態改變。

注:爲什麼要用進程,不用線程?

線程快?進程安全?線程的創建與銷燬消耗資源小?

二. 什麼是進程間通信

首先了解幾個名詞:

1. 進程隔離

進程隔離是爲保護操作系統中進程互不干擾而設計的一組不同硬件和軟件的技術。這個技術是爲了避免進程 A 寫入進程 B 的情況發生。 進程的隔離實現,使用了虛擬地址空間。進程 A 的虛擬地址和進程 B 的虛擬地址不同,這樣就防止進程 A 將數據信息寫入進程 B。

2. 虛擬地址空間

就 32 位系統而言,當創建一個進程時,操作系統會爲該進程分配一個 4GB 大小的虛擬進程地址空間。之所以是 4GB ,是因爲在 32 位的操作系統中,一個指針長度是 4 字節,而 4 字節指針的尋址能力是從 0x00000000~0xFFFFFFFF ,最大值 0xFFFFFFFF 表示的即爲 4GB 大小的容量。與虛擬地址空間相對的,還有一個物理地址空間,這個地址空間對應的是真實的物理內存。要注意的是這個 4GB 的地址空間是 “虛擬” 的,並不是真實存在的,而且每個進程只能訪問自己虛擬地址空間中的數據,無法訪問別的進程中的數據,通過這種方法實現了進程間的地址隔離。

針對 Linux 操作系統,將最高的 1G 字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF )供內核使用,稱爲內核空間,而較低的 3G 字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱爲用戶空間。每個進程都可以通過系統調用進入到內核。其中在 Linux 系統中,進程的用戶空間是獨立的,而內核空間是共有的,進程切換時,用戶空間切換,內核空間不變。

創建虛擬地址空間目的是爲了解決進程地址空間隔離的問題。但程序要想執行,必須運行在真實的內存上,所以,必須在虛擬地址與物理地址間建立一種映射關係。這樣,通過映射機制,當程序訪問虛擬地址空間上的某個地址值時,就相當於訪問了物理地址空間中的另一個值。人們想到了一種分段、分頁的方法,它的思想是在虛擬地址空間和物理地址空間之間做一一映射。這種思想理解起來並不難,操作系統保證不同進程的地址空間被映射到物理地址空間中不同的區域上,這樣每個進程最終訪問到的物理地址空間都是彼此分開的。通過這種方式,就實現了進程間的地址隔離。

進程間通信(IPC,InterProcess Communication)是指在不同進程之間傳播或交換信息。

注:同時在不同終端運行同一個 bin 文件,不同終端的 bin 文件在運行時有什麼是相同的?

三. IPC 通信原理

每個進程各自有不同的用戶地址空間,任何一個進程的全局變量在另一個進程中都看不到,所以進程之間要交換數據必須通過內核, 在內核中開闢一塊緩衝區, 進程 1 把數據從用戶空間拷到內核緩衝區, 進程 2 再從內核緩衝區把數據讀走, 內核提供的這種機制稱爲進程間通信機制。通常的做法是消息發送方將要發送的數據存放在內存緩存區中,通過系統調用進入內核態。然後內核程序在內核空間分配內存,開闢一塊內核緩存區,內核空間調用 copy_from_user() 函數將數據從用戶空間的內存緩存區拷貝到內核空間的內核緩存區中。同樣的,接收方進程在接收數據時在自己的用戶空間開闢一塊內存緩存區,然後內核程序調用 copy_to_user() 函數將數據從內核緩存區拷貝到接收進程的用戶空間內存緩存區。這樣數據發送方進程和數據接收方進程就完成了一次數據傳輸,我們稱完成了一次進程間通信。

主要的過程如下圖所示:

四. 通信方式

IPC 的方式通常有 linux 下的 管道(Streams)(包括匿名管道和命名管道)、消息隊列、信號量、信號、共享內存等。

(一)管道

1 管道 概念

管道是 Unix 中最古老的進程間通信的形式。

我們把從 一個進程連接到另一個進程的一個數據流稱爲一個 “管道”

在 Unix 和類 Unix 系統中,管道通常是通過 | 符號來表示的,它可以將一個進程的標準輸出連接到另一個進程的標準輸入,形成一個數據流的傳輸通道。

管道的基本特點包括:

2 匿名管道 / pipe

pipe() 是一個系統調用,用於創建匿名管道(Anonymous Pipe),它是進程間通信(IPC)的一種簡單機制:

pipe() 函數的原型

#include <unistd.h>
int pipe(int pipefd[2]);

參數

返回值

下面是一個簡單的例子,演示瞭如何使用 pipe() 在父子進程之間進行通信:

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

int main() {
    int pipefd[2];
    pid_t cpid;
    char buf;

    // 創建管道
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }
    // 創建子進程
    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (cpid == 0) { // 子進程
        close(pipefd[1]);          // 關閉寫端
        // 從管道中讀取數據
        while (read(pipefd[0], &buf, 1) > 0) {
            write(STDOUT_FILENO, &buf, 1);
        }
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[0]);  // 關閉讀端
        _exit(EXIT_SUCCESS);
    } else {            // 父進程
        close(pipefd[0]);          // 關閉讀端
        // 向管道中寫入數據
        const char *msg = "Hello from parent";
        write(pipefd[1], msg, sizeof(msg));
        close(pipefd[1]);  // 關閉寫端
        wait(NULL);        // 等待子進程結束
        exit(EXIT_SUCCESS);
    }
}

在這個例子中,父進程創建了一個管道,然後創建了一個子進程。父進程向管道寫入消息,子進程從管道讀取消息並將其輸出到標準輸出

3 理解管道

① 文件描述符角度 理解管道

通過下面三步去理解:

通過這種方式,父進程和子進程利用共享的文件描述符實現了進程間的通信,即管道在文件描述符層面上的工作原理。

② 內核角度 探尋管道本質

所以,看待管道,就如同看待文件一樣!管道的使用和文件一致,迎合了 “Linux 一切皆文件思想”

4 管道讀寫規則

對於不同情況,管道有以下的讀寫規則:

  1. 讀端沒有數據可讀時的行爲:
  1. 管道滿時的行爲:
  1. 管道關閉的影響:
  1. 原子性的保證:

5 管道的特點

  1. 親緣關係進程之間通信:
  1. 流式通信:
  1. 管道生命週期與進程相關:
  1. 管道生命週期與進程相關:
  1. 半雙工特性:
  1. 數據傳輸的原子性:

(利用管道實現雙向通信)

6 命名管道

命名管道(Named Pipe)是一種特殊類型的文件,它允許無關的進程間進行雙向通信。:與匿名管道不同的是,命名管道可以在文件系統中被命名,並通過文件名來進行訪問,因此它也被稱爲 FIFO(First In, First Out)。

① 特點和用途:

  1. 文件系統中的特殊文件:
  1. 無關進程的通信:
  1. 雙向通信:
  1. 生命週期與文件系統相關聯:
  1. 用途:

② 創建和使用命名管道:

在 Unix/Linux 系統中,可以直接在命令行使用命令 mkfifo 來創建命名管道,例如:

mkfifo mypipe

這將在當前目錄創建一個名爲 mypipe 的命名管道文件。然後可以像操作普通文件一樣,通過文件名在不同的進程中進行數據讀寫操作。

命名管道也可以在程序中創建,利用 mkfifo:

int mkfifo(const char *filename,mode_t mode);

③ 注意事項:

④ 命名管道的打開規則

  1. 以讀模式打開 FIFO:
  1. 以寫模式打開 FIFO:

⑤ 實例 1:用命名管道實現文件拷貝

我們用示例代碼展示:如何使用命名管道在兩個進程之間進行文件拷貝:

  1. 讀取進程(讀文件並寫入命名管道):
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_FILE "myfifo"
#define MAX_BUFFER_SIZE 1024

int main() {
    int fd_fifo;
    FILE *fp_source;
    char buffer[MAX_BUFFER_SIZE];
    // 打開命名管道(寫模式)
    fd_fifo = open(FIFO_FILE, O_WRONLY);
    // 打開源文件(需要拷貝的文件)
    fp_source = fopen("source.txt", "r");
    // 從源文件讀取內容,並寫入命名管道
    while (fgets(buffer, MAX_BUFFER_SIZE, fp_source) != NULL) {
        write(fd_fifo, buffer, sizeof(buffer));
    }
    // 關閉文件和管道
    fclose(fp_source);
    close(fd_fifo);
    return 0;
}
  1. 寫入進程(從命名管道讀取數據並寫入目標文件):
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO_FILE "myfifo"
#define MAX_BUFFER_SIZE 1024

int main() {
    int fd_fifo;
    FILE *fp_dest;
    char buffer[MAX_BUFFER_SIZE];
    ssize_t bytes_read;
    // 打開命名管道(讀模式)
    fd_fifo = open(FIFO_FILE, O_RDONLY);
    // 創建目標文件(拷貝後的文件)
    fp_dest = fopen("destination.txt", "w");
    // 從命名管道讀取內容,並寫入目標文件
    while ((bytes_read = read(fd_fifo, buffer, sizeof(buffer))) > 0) {
        fwrite(buffer, 1, bytes_read, fp_dest);
    }
    // 關閉文件和管道
    fclose(fp_dest);
    close(fd_fifo);
    return 0;
}

此時只需執行讀進程與寫進程就可以直接實現文件拷貝

⑥ 實例 2:用命名管道實現 server&client 通信

在這個例子中,我們將展示如何使用命名管道(FIFO)來實現一個簡單的服務器(server)和客戶端(client)之間的通信。服務器將從客戶端接收消息,並且可以向客戶端發送響應。

服務器端

// server.c

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

#define FIFO_FILE "myfifo"

int main() { 
    int fd_fifo;
    char read_buffer[BUFSIZ];
    char write_buffer[BUFSIZ];
    int bytes_read;
    // 創建命名管道(如果不存在)
    mkfifo(FIFO_FILE, 0666);
    printf("Server started, waiting for clients...\n");
    // 打開命名管道(讀模式)
    fd_fifo = open(FIFO_FILE, O_RDONLY);
    while (1) {
        // 從命名管道中讀取數據
        bytes_read = read(fd_fifo, read_buffer, sizeof(read_buffer));
        if (bytes_read > 0) {
            read_buffer[bytes_read] = '\0';
            printf("Received: %s\n", read_buffer);
            // 模擬處理消息(這裏簡單地回覆消息)
            sprintf(write_buffer, "Server received: %s", read_buffer);
            // 打開命名管道(寫模式)
            int fd_write = open(FIFO_FILE, O_WRONLY);
            write(fd_write, write_buffer, strlen(write_buffer) + 1);
            close(fd_write);
        }
    }
    // 關閉命名管道
    close(fd_fifo);
    unlink(FIFO_FILE);
    return 0;
}

客戶端

// client.c

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

#define FIFO_FILE "myfifo"

int main() {
    int fd_fifo;
    char write_buffer[BUFSIZ];
    char read_buffer[BUFSIZ];
    int bytes_read;
    // 打開命名管道(寫模式)
    fd_fifo = open(FIFO_FILE, O_WRONLY);
    while (1) {
        // 從標準輸入讀取消息
        printf("Enter message to send: ");
        fgets(write_buffer, sizeof(write_buffer), stdin);
        write_buffer[strlen(write_buffer) - 1] = '\0'; // 去除末尾的換行符
        // 將消息寫入命名管道
        write(fd_fifo, write_buffer, strlen(write_buffer) + 1);
        // 打開命名管道(讀模式)
        int fd_read = open(FIFO_FILE, O_RDONLY);
        // 讀取服務器的響應
        bytes_read = read(fd_read, read_buffer, sizeof(read_buffer));
        if (bytes_read > 0) {
            printf("Server response: %s\n", read_buffer);
        }
        close(fd_read);
    }
    // 關閉命名管道
    close(fd_fifo);
    return 0;
}

匿名管道與命名管道的區別

有以下主要區別:

  1. 命名和訪問方式:
  1. 進程關係要求:
  1. 生命週期和持久性:
  1. 命名和訪問方式:
  1. 訪問權限:

匿名管道適用於簡單的進程間通信需求,而命名管道則提供了更靈活和持久的通信方式,適用於更復雜和長期的通信場景。

(二)消息隊列

1. 什麼是消息隊列

消息隊列提供了一種從一個進程向另一個進程發送一個數據塊的方法。  每個數據塊都被認爲含有一個類型,接收進程可以獨立地接收含有不同類型的數據結構。我們可以通過發送消息來避免命名管道的同步和阻塞問題。但是消息隊列與命名管道一樣,每個數據塊都有一個最大長度的限制。

Linux 用宏 MSGMAX 和 MSGMNB 來限制一條消息的最大長度和一個隊列的最大長度。

2. 在 Linux 中使用消息隊列

Linux 提供了一系列消息隊列的函數接口來讓我們方便地使用它來實現進程間的通信。它的用法與其他兩個 System V PIC 機制,即信號量和共享內存相似。

1、msgget 函數

該函數用來創建和訪問一個消息隊列。它的原型爲:

int msgget(key_t, key, int msgflg);

與其他的 IPC 機制一樣,程序必須提供一個鍵來命名某個特定的消息隊列。msgflg 是一個權限標誌,表示消息隊列的訪問權限,它與文件的訪問權限一樣。msgflg 可以與 IPC_CREAT 做或操作,表示當 key 所命名的消息隊列不存在時創建一個消息隊列,如果 key 所命名的消息隊列存在時,IPC_CREAT 標誌會被忽略,而只返回一個標識符。

它返回一個以 key 命名的消息隊列的標識符(非零整數),失敗時返回 - 1.

2、msgsnd 函數

該函數用來把消息添加到消息隊列中。它的原型爲:

int msgsend(int msgid, const void *msg_ptr, size_t msg_sz, int msgflg);

msgid 是由 msgget 函數返回的消息隊列標識符。

msg_ptr 是一個指向準備發送消息的指針,但是消息的數據結構卻有一定的要求,指針 msg_ptr 所指向的消息結構一定要是以一個長整型成員變量開始的結構體,接收函數將用這個成員來確定消息的類型。所以消息結構要定義成這樣:

msg_sz 是 msg_ptr 指向的消息的長度,注意是消息的長度,而不是整個結構體的長度,也就是說 msg_sz 是不包括長整型消息類型成員變量的長度。

msgflg 用於控制當前消息隊列滿或隊列消息到達系統範圍的限制時將要發生的事情。

如果調用成功,消息數據的一分副本將被放到消息隊列中,並返回 0,失敗時返回 - 1.

3、msgrcv 函數

該函數用來從一個消息隊列獲取消息,它的原型爲

int msgrcv(int msgid, void *msg_ptr, size_t msg_st, long int msgtype, int msgflg);

msgid, msg_ptr, msg_st 的作用也函數 msgsnd 函數的一樣。

msgtype 可以實現一種簡單的接收優先級。如果 msgtype 爲 0,就獲取隊列中的第一個消息。如果它的值大於零,將獲取具有相同消息類型的第一個信息。如果它小於零,就獲取類型等於或小於 msgtype 的絕對值的第一個消息。

msgflg 用於控制當隊列中沒有相應類型的消息可以接收時將發生的事情。

調用成功時,該函數返回放到接收緩存區中的字節數,消息被複制到由 msg_ptr 指向的用戶分配的緩存區中,然後刪除消息隊列中的對應消息。失敗時返回 - 1.

4、msgctl 函數

該函數用來控制消息隊列,它與共享內存的 shmctl 函數相似,它的原型爲:

int msgctl(int msgid, int command, struct msgid_ds *buf);

command 是將要採取的動作,它可以取 3 個值,

buf 是指向 msgid_ds 結構的指針,它指向消息隊列模式和訪問權限的結構。msgid_ds 結構至少包括以下成員:

成功時返回 0,失敗時返回 - 1.

3. 使用消息隊列進行進程間通信

馬不停蹄,介紹完消息隊列的定義和可使用的接口之後,我們來看看它是怎麼讓進程進行通信的。由於可以讓不相關的進程進行行通信,所以我們在這裏將會編寫兩個程序,msgreceive 和 msgsned 來表示接收和發送信息。根據正常的情況,我們允許兩個程序都可以創建消息,但只有接收者在接收完最後一個消息之後,它才把它刪除。

接收信息的程序源文件爲 msgreceive.c 的源代碼爲:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/msg.h>
struct msg_st{
  long int msg_type;
  char text[BUFSIZ];
};

int main(){
  int running = 1;
  int msgid = -1;
  struct msg_st data;
  long int msgtype = 0; //注意1
  //建立消息隊列
  msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
  if(msgid == -1)  {
    fprintf(stderr, "msgget failed with error: %d\n", errno);
    exit(EXIT_FAILURE);
  }  //從隊列中獲取消息,直到遇到end消息爲止
  while(running)  {
    if(msgrcv(msgid, (void*)&data, BUFSIZ, msgtype, 0) == -1)    {
      fprintf(stderr, "msgrcv failed with errno: %d\n", errno);
      exit(EXIT_FAILURE);
    }
    printf("You wrote: %s\n",data.text);
    //遇到end結束
    if(strncmp(data.text, "end", 3) == 0)      running = 0;
  }
  //刪除消息隊列
  if(msgctl(msgid, IPC_RMID, 0) == -1)  {
    fprintf(stderr, "msgctl(IPC_RMID) failed\n");
    exit(EXIT_FAILURE);
  }
  exit(EXIT_SUCCESS);
}

發送信息的程序的源文件 msgsend.c 的源代碼爲:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/msg.h>
#include <errno.h>
#define MAX_TEXT 512

struct msg_st{
  long int msg_type;
  char text[MAX_TEXT];
};

int main(){
  int running = 1;
  struct msg_st data;
  char buffer[BUFSIZ];
  int msgid = -1;
   //建立消息隊列
  msgid = msgget((key_t)1234, 0666 | IPC_CREAT);
  if(msgid == -1)  {
    fprintf(stderr, "msgget failed with error: %d\n", errno);
    exit(EXIT_FAILURE);
  }   
  //向消息隊列中寫消息,直到寫入end
  while(running)  {
    //輸入數據
    printf("Enter some text: ");
    fgets(buffer, BUFSIZ, stdin);
    data.msg_type = 1;    //注意2
    strcpy(data.text, buffer);
    //向隊列發送數據
    if(msgsnd(msgid, (void*)&data, MAX_TEXT, 0) == -1)    {
      fprintf(stderr, "msgsnd failed\n");
      exit(EXIT_FAILURE);
    }
    //輸入end結束輸入
    if(strncmp(buffer, "end", 3) == 0)
      running = 0;
    sleep(1);
  }
  exit(EXIT_SUCCESS);
}

運行結果如下:

4. 例子分析——消息類型

這裏主要說明一下消息類型是怎麼一回事,注意 msgreceive.c 文件 main 函數中定義的變量 msgtype(註釋爲注意 1),它作爲 msgrcv 函數的接收信息類型參數的值,其值爲 0,表示獲取隊列中第一個可用的消息。再來看看 msgsend.c 文件中 while 循環中的語句 data.msg_type = 1(註釋爲注意 2),它用來設置發送的信息的信息類型,即其發送的信息的類型爲 1。所以程序 msgreceive 能夠接收到程序 msgsend 發送的信息。

如果把注意 1,即 msgreceive.c 文件 main 函數中的語句由 long int msgtype = 0; 改變爲 long int msgtype = 2; 會發生什麼情況,msgreceive 將不能接收到程序 msgsend 發送的信息。因爲在調用 msgrcv 函數時,如果 msgtype(第四個參數)大於零,則將只獲取具有相同消息類型的第一個消息,修改後獲取的消息類型爲 2,而 msgsend 發送的消息類型爲 1,所以不能被 msgreceive 程序接收。重新編譯 msgreceive.c 文件並再次執行,其結果如下:

我們可以看到,msgreceive 並沒有接收到信息和輸出,而且當 msgsend 輸入 end 結束後,msgreceive 也沒有結束,通過 jobs 命令我們可以看到它還在後臺運行着。

5. 消息隊列與命名管道的比較

消息隊列跟命名管道有不少的相同之處,通過與命名管道一樣,消息隊列進行通信的進程可以是不相關的進程,同時它們都是通過發送和接收的方式來傳遞數據的。在命名管道中,發送數據用 write,接收數據用 read,則在消息隊列中,發送數據用 msgsnd,接收數據用 msgrcv。而且它們對每個數據都有一個最大長度的限制。

與命名管道相比,消息隊列的優勢在於,1、消息隊列也可以獨立於發送和接收進程而存在,從而消除了在同步命名管道的打開和關閉時可能產生的困難。2、同時通過發送消息還可以避免命名管道的同步和阻塞問題,不需要由進程自己來提供同步方法。3、接收程序可以通過消息類型有選擇地接收數據,而不是像命名管道中那樣,只能默認地接收。

(三)共享內存

1、什麼是共享內存

顧名思義,共享內存就是允許兩個不相關的進程訪問同一個邏輯內存。共享內存是在兩個正在運行的進程之間共享和傳遞數據的一種非常有效的方式。不同進程之間共享的內存通常安排爲同一段物理內存。進程可以將同一段共享內存連接到它們自己的地址空間中,所有進程都可以訪問共享內存中的地址,就好像它們是由用 C 語言函數 malloc 分配的內存一樣。而如果某個進程向共享內存寫入數據,所做的改動將立即影響到可以訪問同一段共享內存的任何其他進程。

2、共享內存的使得

在 Linux 中也提供了一組函數接口用於使用共享內存,而且使用共享共存的接口還與信號量的非常相似,而且比使用信號量的接口來得簡單。它們聲明在頭文件 sys/shm.h 中。

1、shmget 函數

該函數用來創建共享內存,它的原型爲:

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

第一個參數,程序需要提供一個參數 key(非 0 整數),它有效地爲共享內存段命名,shmget 函數成功時返回一個與 key 相關的共享內存標識符(非負整數),用於後續的共享內存函數。調用失敗返回 - 1.

不相關的進程可以通過該函數的返回值訪問同一共享內存,它代表程序可能要使用的某個資源,程序對所有共享內存的訪問都是間接的,程序先通過調用 shmget 函數並提供一個鍵,再由系統生成一個相應的共享內存標識符(shmget 函數的返回值),只有 shmget 函數才直接使用信號量鍵,所有其他的信號量函數使用由 semget 函數返回的信號量標識符。

第二個參數,size 以字節爲單位指定需要共享的內存容量

第三個參數,shmflg 是權限標誌,它的作用與 open 函數的 mode 參數一樣,如果要想在 key 標識的共享內存不存在時,創建它的話,可以與 IPC_CREAT 做或操作。共享內存的權限標誌與文件的讀寫權限一樣,舉例來說,0644, 它表示允許一個進程創建的共享內存被內存創建者所擁有的進程向共享內存讀取和寫入數據,同時其他用戶創建的進程只能讀取共享內存。

2、shmat 函數

第一次創建完共享內存時,它還不能被任何進程訪問,shmat 函數的作用就是用來啓動對該共享內存的訪問,並把共享內存連接到當前進程的地址空間。它的原型如下:

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

第一個參數,shm_id 是由 shmget 函數返回的共享內存標識。

第二個參數,shm_addr 指定共享內存連接到當前進程中的地址位置,通常爲空,表示讓系統來選擇共享內存的地址。

第三個參數,shm_flg 是一組標誌位,通常爲 0。

調用成功時返回一個指向共享內存第一個字節的指針,如果調用失敗返回 - 1.

3、shmdt 函數

該函數用於將共享內存從當前進程中分離。注意,將共享內存分離並不是刪除它,只是使該共享內存對當前進程不再可用。它的原型如下:

int shmdt(const void *shmaddr);

參數 shmaddr 是 shmat 函數返回的地址指針,調用成功時返回 0,失敗時返回 - 1.

4、shmctl 函數

與信號量的 semctl 函數一樣,用來控制共享內存,它的原型如下:

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

第一個參數 ,shm_id 是 shmget 函數返回的共享內存標識符。

第二個參數,command 是要採取的操作,它可以取下面的三個值 :

第三個參數,buf 是一個結構指針,它指向共享內存模式和訪問權限的結構。

shmid_ds 結構至少包括以下成員:

struct shmid_ds{
    uid_t shm_perm.uid;
    uid_t shm_perm.gid;
    mode_t shm_perm.mode;
};

說了這麼多,又到了實戰的時候了。下面就以兩個不相關的進程來說明進程間如何通過共享內存來進行通信。其中一個文件 shmread.c 創建共享內存,並讀取其中的信息,另一個文件 shmwrite.c 向共享內存中寫入數據。爲了方便操作和數據結構的統一,爲這兩個文件定義了相同的數據結構,定義在文件 shmdata.c 中。結構 shared_use_st 中的 written 作爲一個可讀或可寫的標誌,非 0:表示可讀,0 表示可寫,text 則是內存中的文件。

shmdata.h 的源代碼如下:

#ifndef _SHMDATA_H_HEADER
#define _SHMDATA_H_HEADER 
#define TEXT_SZ 2048 

struct shared_use_st{
  int written;//作爲一個標誌,非0:表示可讀,0表示可寫
  char text[TEXT_SZ];//記錄寫入和讀取的文本
}; 
#endif

源文件 shmread.c 的源代碼如下:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/shm.h>
#include "shmdata.h" 

int main(){
  int running = 1;//程序是否繼續運行的標誌
  void *shm = NULL;//分配的共享內存的原始首地址
  struct shared_use_st *shared;//指向shm
  int shmid;//共享內存標識符  //創建共享內存
  shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
  if(shmid == -1)  {
    fprintf(stderr, "shmget failed\n");
    exit(EXIT_FAILURE);
  }
  //將共享內存連接到當前進程的地址空間
  shm = shmat(shmid, 0, 0);
  if(shm == (void*)-1)  {
    fprintf(stderr, "shmat failed\n");
    exit(EXIT_FAILURE);
  }
  printf("\nMemory attached at %X\n", (int)shm);
  //設置共享內存
  shared = (struct shared_use_st*)shm;
  shared->written = 0;
  while(running)//讀取共享內存中的數據  {
    //沒有進程向共享內存定數據有數據可讀取
    if(shared->written != 0)    {
      printf("You wrote: %s", shared->text);
      sleep(rand() % 3);
      //讀取完數據,設置written使共享內存段可寫
      shared->written = 0;
      //輸入了end,退出循環(程序)
      if(strncmp(shared->text, "end", 3) == 0)
        running = 0;
    }
    else//有其他進程在寫數據,不能讀取數據
      sleep(1);
  }
  //把共享內存從當前進程中分離
  if(shmdt(shm) == -1)  {
    fprintf(stderr, "shmdt failed\n"); 
    exit(EXIT_FAILURE);
  }
  //刪除共享內存
  if(shmctl(shmid, IPC_RMID, 0) == -1)  {
    fprintf(stderr, "shmctl(IPC_RMID) failed\n");
    exit(EXIT_FAILURE);
  }
  exit(EXIT_SUCCESS);
}

源文件 shmwrite.c 的源代碼如下:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/shm.h>
#include "shmdata.h" 

int main(){
  int running = 1;
  void *shm = NULL;
  struct shared_use_st *shared = NULL;
  char buffer[BUFSIZ + 1];//用於保存輸入的文本
  int shmid;
  //創建共享內存
  shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
  if(shmid == -1)  {
    fprintf(stderr, "shmget failed\n");
    exit(EXIT_FAILURE);
  }  
  //將共享內存連接到當前進程的地址空間
  shm = shmat(shmid, (void*)0, 0);
  if(shm == (void*)-1)  {
    fprintf(stderr, "shmat failed\n");
    exit(EXIT_FAILURE);
  }
  printf("Memory attached at %X\n", (int)shm);
  //設置共享內存
  shared = (struct shared_use_st*)shm;
  while(running)//向共享內存中寫數據  {
    //數據還沒有被讀取,則等待數據被讀取,不能向共享內存中寫入文本
    while(shared->written == 1)    {
      sleep(1);
      printf("Waiting...\n");
    }
  //向共享內存中寫入數據
  printf("Enter some text: "); 
  fgets(buffer, BUFSIZ, stdin);    
  strncpy(shared->text, buffer, TEXT_SZ);
  //寫完數據,設置written使共享內存段可讀    
  shared->written = 1; 
  //輸入了end,退出循環(程序)
  if(strncmp(buffer, "end", 3) == 0)
    running = 0;
}
//把共享內存從當前進程中分離  
if(shmdt(shm) == -1)  {
  fprintf(stderr, "shmdt failed\n");
  exit(EXIT_FAILURE);  
}  
sleep(2);
exit(EXIT_SUCCESS);
}

再來看看運行的結果:

分析:

1、程序 shmread 創建共享內存,然後將它連接到自己的地址空間。在共享內存的開始處使用了一個結構 struct_use_st。該結構中有個標誌 written,當共享內存中有其他進程向它寫入數據時,共享內存中的 written 被設置爲 0,程序等待。當它不爲 0 時,表示沒有進程對共享內存寫入數據,程序就從共享內存中讀取數據並輸出,然後重置設置共享內存中的 written 爲 0,即讓其可被 shmwrite 進程寫入數據。

2、程序 shmwrite 取得共享內存並連接到自己的地址空間中。檢查共享內存中的 written,是否爲 0,若不是,表示共享內存中的數據還沒有被完,則等待其他進程讀取完成,並提示用戶等待。若共享內存的 written 爲 0,表示沒有其他進程對共享內存進行讀取,則提示用戶輸入文本,並再次設置共享內存中的 written 爲 1,表示寫完成,其他進程可對共享內存進行讀操作。

4、關於前面的例子的安全性討論

這個程序是不安全的,當有多個程序同時向共享內存中讀寫數據時,問題就會出現。可能你會認爲,可以改變一下 written 的使用方式,例如,只有當 written 爲 0 時進程纔可以向共享內存寫入數據,而當一個進程只有在 written 不爲 0 時才能對其進行讀取,同時把 written 進行加 1 操作,讀取完後進行減 1 操作。這就有點像文件鎖中的讀寫鎖的功能。咋看之下,它似乎能行得通。但是這都不是原子操作,所以這種做法是行不能的。試想當 written 爲 0 時,如果有兩個進程同時訪問共享內存,它們就會發現 written 爲 0,於是兩個進程都對其進行寫操作,顯然不行。當 written 爲 1 時,有兩個進程同時對共享內存進行讀操作時也是如些,當這兩個進程都讀取完是,written 就變成了 - 1.

要想讓程序安全地執行,就要有一種進程同步的進制,保證在進入臨界區的操作是原子操作。例如,可以使用前面所講的信號量來進行進程的同步。因爲信號量的操作都是原子性的。

5、使用共享內存的優缺點

1、優點:我們可以看到使用共享內存進行進程間的通信真的是非常方便,而且函數的接口也簡單,數據的共享還使進程間的數據不用傳送,而是直接訪問內存,也加快了程序的效率。同時,它也不像匿名管道那樣要求通信的進程有一定的父子關係。

2、缺點:共享內存沒有提供同步的機制,這使得我們在使用共享內存進行進程間通信時,往往要藉助其他的手段來進行進程間的同步工作。

(四)信號

1、什麼是信號

用過 Windows 的我們都知道,當我們無法正常結束一個程序時,可以用任務管理器強制結束這個進程,但這其實是怎麼實現的呢?同樣的功能在 Linux 上是通過生成信號和捕獲信號來實現的,運行中的進程捕獲到這個信號然後作出一定的操作並最終被終止。

信號是 UNIX 和 Linux 系統響應某些條件而產生的一個事件,接收到該信號的進程會相應地採取一些行動。通常信號是由一個錯誤產生的。但它們還可以作爲進程間通信或修改行爲的一種方式,明確地由一個進程發送給另一個進程。一個信號的產生叫生成,接收到一個信號叫捕獲。

2、信號的種類

信號的名稱是在頭文件 signal.h 中定義的,信號都以 SIG 開頭,常用的信號並不多,常用的信號如下:

更多的信號類型可查看附錄表。

3、信號的處理——signal 函數

程序可用使用 signal 函數來處理指定的信號,主要通過忽略和恢復其默認行爲來工作。signal 函數的原型如下:

#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);

這是一個相當複雜的聲明,耐心點看可以知道 signal 是一個帶有 sig 和 func 兩個參數的函數,func 是一個類型爲 void (*)(int) 的函數指針。該函數返回一個與 func 相同類型的指針,指向先前指定信號處理函數的函數指針。準備捕獲的信號的參數由 sig 給出,接收到的指定信號後要調用的函數由參數 func 給出。其實這個函數的使用是相當簡單的,通過下面的例子就可以知道。注意信號處理函數的原型必須爲 void func(int),或者是下面的特殊值:

說了這麼多,還是給出一個例子來說明一下吧,源文件名爲 signal1.c,代碼如下:

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

void ouch(int sig){
  printf("\nOUCH! - I got signal %d\n", sig);
  //恢復終端中斷信號SIGINT的默認行爲
  (void) signal(SIGINT, SIG_DFL);
}

int main(){
  //改變終端中斷信號SIGINT的默認行爲,使之執行ouch函數
  //而不是終止程序的執行
  (void) signal(SIGINT, ouch);
  while(1)  {
    printf("Hello World!\n");
    sleep(1);
  }
  return 0;
}

運行結果如下:

可以看到,第一次按下終止命令(ctrl+c)時,進程並沒有被終止,面是輸出 OUCH! - I got signal 2,因爲 SIGINT 的默認行爲被 signal 函數改變了,當進程接受到信號 SIGINT 時,它就去調用函數 ouch 去處理,注意 ouch 函數把信號 SIGINT 的處理方式改變成默認的方式,所以當你再按一次 ctrl+c 時,進程就像之前那樣被終止了。

4、信號處理——sigaction 函數

前面我們看到了 signal 函數對信號的處理,但是一般情況下我們可以使用一個更加健壯的信號接口——sigaction 函數。它的原型爲:

#include <signal.h>int sigaction(int sig, const struct sigaction *act, struct sigaction *oact);

該函數與 signal 函數一樣,用於設置與信號 sig 關聯的動作,而 oact 如果不是空指針的話,就用它來保存原先對該信號的動作的位置,act 則用於設置指定信號的動作。

sigaction 結構體定義在 signal.h 中,但是它至少包括以下成員:

void (*) (int) sa_handler; 處理函數指針,相當於 signal 函數的 func 參數。

sigset_t sa_mask; 指定一個。信號集,在調用 sa_handler 所指向的信號處理函數之前,該信號集將被加入到進程的信號屏蔽字中。信號屏蔽字是指當前被阻塞的一組信號,它們不能被當前進程接收到

int sa_flags; 信號處理修改器;

sa_mask 的值通常是通過使用信號集函數來設置的。

sa_flags,通常可以取以下的值:

此外,現在有一個這樣的問題,我們使用 signal 或 sigaction 函數來指定處理信號的函數,但是如果這個信號處理函數建立之前就接收到要處理的信號的話,進程會有怎樣的反應呢?它就不會像我們想像的那樣用我們設定的處理函數來處理了。sa_mask 就可以解決這樣的問題,sa_mask 指定了一個信號集,在調用 sa_handler 所指向的信號處理函數之前,該信號集將被加入到進程的信號屏蔽字中,設置信號屏蔽字可以防止信號在它的處理函數還未運行結束時就被接收到的情況,即使用 sa_mask 字段可以消除這一競態條件。

承接上面的例子,下面給出用 sigaction 函數重寫的例子代碼,源文件爲 signal2.c,代碼如下:

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

void ouch(int sig){
  printf("\nOUCH! - I got signal %d\n", sig);
}

int main(){
  struct sigaction act;
  act.sa_handler = ouch;
  //創建空的信號屏蔽字,即不屏蔽任何信息
  sigemptyset(&act.sa_mask);
  //使sigaction函數重置爲默認行爲
   act.sa_flags = SA_RESETHAND;
   sigaction(SIGINT, &act, 0);
   while(1)  {
    printf("Hello World!\n");
    sleep(1);
  }
  return 0;
}

運行結果與前一個例子中的相同。注意 sigaction 函數在默認情況下是不被重置的,如果要想它重置,則 sa_flags 就要爲 SA_RESETHAND。

5、發送信號

上面說到的函數都是一些進程接收到一個信號之後怎麼對這個信號作出反應,即信號的處理的問題,有沒有什麼函數可以向一個進程主動地發出一個信號呢?我們可以通過兩個函數 kill 和 alarm 來發送一個信號。

1、kill 函數

先來看看 kill 函數,進程可以通過 kill 函數向包括它本身在內的其他進程發送一個信號,如果程序沒有發送這個信號的權限,對 kill 函數的調用就將失敗,而失敗的常見原因是目標進程由另一個用戶所擁有。想一想也是容易明白的,你總不能控制別人的程序吧,當然超級用戶 root,這種上帝般的存在就除外了。

kill 函數的原型爲:

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

它的作用把信號 sig 發送給進程號爲 pid 的進程,成功時返回 0。

kill 調用失敗返回 - 1,調用失敗通常有三大原因:

1、給定的信號無效(errno = EINVAL)

2、發送權限不夠 ( errno = EPERM )

3、目標進程不存在 (errno = ESRCH)

2、alarm 函數

這個函數跟它的名字一樣,給我們提供了一個鬧鐘的功能,進程可以調用 alarm 函數在經過預定時間後向發送一個 SIGALRM 信號。

alarm 函數的型如下:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

alarm 函數用來在 seconds 秒之後安排發送一個 SIGALRM 信號,如果 seconds 爲 0,將取消所有已設置的鬧鐘請求。alarm 函數的返回值是以前設置的鬧鐘時間的餘留秒數,如果返回失敗返回 - 1。

馬不停蹄,下面就給合 fork、sleep 和 signal 函數,用一個例子來說明 kill 函數的用法吧,源文件爲 signal3.c,代碼如下:

#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <stdio.h>
#include <signal.h>

 static int alarm_fired = 0;
 void ouch(int sig){
  alarm_fired = 1;
} 

int main(){
  pid_t pid;
  pid = fork();
  switch(pid)  {
  case -1:
    perror("fork failed\n");
    exit(1);
  case 0:
    //子進程
    sleep(5);
    //向父進程發送信號
    kill(getppid(), SIGALRM);
    exit(0);
  default:;
  }
  //設置處理函數
  signal(SIGALRM, ouch);
  while(!alarm_fired)  {
    printf("Hello World!\n");
    sleep(1);
  }
  if(alarm_fired)
    printf("\nI got a signal %d\n", SIGALRM);
   exit(0);
}

運行結果如下:

在代碼中我們使用 fork 調用複製了一個新進程,在子進程中,5 秒後向父進程中發送一個 SIGALRM 信號,父進程中捕獲這個信號,並用 ouch 函數來處理,變改 alarm_fired 的值,然後退出循環。從結果中我們也可以看到輸出了 5 個 Hello World!之後,程序就收到一個 SIGARLM 信號,然後結束了進程。

注:如果父進程在子進程的信號到來之前沒有事情可做,我們可以用函數 pause()來掛起父進程,直到父進程接收到信號。當進程接收到一個信號時,預設好的信號處理函數將開始運行,程序也將恢復正常的執行。這樣可以節省 CPU 的資源,因爲可以避免使用一個循環來等待。以本例子爲例,則可以把 while 循環改爲一句 pause();

下面再以一個小小的例子來說明 alarm 函數和 pause 函數的用法吧,源文件名爲,signal4.c,代碼如下:

#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <stdio.h>
#include <signal.h> 

static int alarm_fired = 0;

void ouch(int sig){
  alarm_fired = 1;
} 


int main(){
  //關聯信號處理函數
  signal(SIGALRM, ouch);
  //調用alarm函數,5秒後發送信號SIGALRM
  alarm(5);
  //掛起進程
  pause();
  //接收到信號後,恢復正常執行
  if(alarm_fired == 1)
    printf("Receive a signal %d\n", SIGALRM);
  exit(0);
}

運行結果如下:

進程在 5 秒後接收到一個 SIGALRM,進程恢復運行,打印信息並退出。

6、信號處理函數的安全問題

試想一個問題,當進程接收到一個信號時,轉到你關聯的函數中執行,但是在執行的時候,進程又接收到同一個信號或另一個信號,又要執行相關聯的函數時,程序會怎麼執行?

也就是說,信號處理函數可以在其執行期間被中斷並被再次調用。當返回到第一次調用時,它能否繼續正確操作是很關鍵的。這不僅僅是遞歸的問題,而是可重入的(即可以完全地進入和再次執行)的問題。而反觀 Linux,其內核在同一時期負責處理多個設備的中斷服務例程就需要可重入的,因爲優先級更高的中斷可能會在同一段代碼的執行期間 “插入” 進來。

簡言之,就是說,我們的信號處理函數要是可重入的,即離開後可再次安全地進入和再次執行,要使信號處理函數是可重入的,則在信息處理函數中不能調用不可重入的函數。下面給出可重入的函數在列表,不在此表中的函數都是不可重入的, 可重入函數表如下:

7、附錄——信號表

如果進程接收到上面這些信號中的一個,而事先又沒有安排捕獲它,進程就會終止。

還有其他的一些信號,如下:

###(五)信號量

1. 什麼是信號量

爲了防止出現因多個程序同時訪問一個共享資源而引發的一系列問題,我們需要一種方法,它可以通過生成並使用令牌來授權,在任一時刻只能有一個執行線程訪問代碼的臨界區域。臨界區域是指執行數據更新的代碼需要獨佔式地執行。而信號量就可以提供這樣的一種訪問機制,讓一個臨界區同一時間只有一個線程在訪問它,也就是說信號量是用來調協進程對共享資源的訪問的。

信號量是一個特殊的變量,程序對其訪問都是原子操作,且只允許對它進行等待(即 P(信號變量)) 和發送(即 V(信號變量)) 信息操作。最簡單的信號量是隻能取 0 和 1 的變量,這也是信號量最常見的一種形式,叫做二進制信號量。而可以取多個正整數的信號量被稱爲通用信號量。這裏主要討論二進制信號量。

2. 信號量的工作原理

由於信號量只能進行兩種操作等待和發送信號,即 P(sv) 和 V(sv), 他們的行爲是這樣的:

P(sv):如果 sv 的值大於零,就給它減 1;如果它的值爲零,就掛起該進程的執行

V(sv):如果有其他進程因等待 sv 而被掛起,就讓它恢復運行,如果沒有進程因等待 sv 而掛起,就給它加 1.

舉個例子,就是兩個進程共享信號量 sv,一旦其中一個進程執行了 P(sv) 操作,它將得到信號量,並可以進入臨界區,使 sv 減 1。而第二個進程將被阻止進入臨界區,因爲當它試圖執行 P(sv) 時,sv 爲 0,它會被掛起以等待第一個進程離開臨界區域並執行 V(sv) 釋放信號量,這時第二個進程就可以恢復執行。

3.Linux 的信號量機制

Linux 提供了一組精心設計的信號量接口來對信號進行操作,它們不只是針對二進制信號量,下面將會對這些函數進行介紹,但請注意,這些函數都是用來對成組的信號量值進行操作的。它們聲明在頭文件 sys/sem.h 中。

1、semget 函數

它的作用是創建一個新信號量或取得一個已有信號量,原型爲:

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

第一個參數 key 是整數值(唯一非零),不相關的進程可以通過它訪問一個信號量,它代表程序可能要使用的某個資源,程序對所有信號量的訪問都是間接的,程序先通過調用 semget 函數並提供一個鍵,再由系統生成一個相應的信號標識符(semget 函數的返回值),只有 semget 函數才直接使用信號量鍵,所有其他的信號量函數使用由 semget 函數返回的信號量標識符。如果多個程序使用相同的 key 值,key 將負責協調工作。

第二個參數 num_sems 指定需要的信號量數目,它的值幾乎總是 1。

第三個參數 sem_flags 是一組標誌,當想要當信號量不存在時創建一個新的信號量,可以和值 IPC_CREAT 做按位或操作。設置了 IPC_CREAT 標誌後,即使給出的鍵是一個已有信號量的鍵,也不會產生錯誤。而 IPC_CREAT | IPC_EXCL 則可以創建一個新的,唯一的信號量,如果信號量已存在,返回一個錯誤。

semget 函數成功返回一個相應信號標識符(非零),失敗返回 - 1.

2、semop 函數

它的作用是改變信號量的值,原型爲:

int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops);
struct sembuf{
    short sem_num;//除非使用一組信號量,否則它爲0
    short sem_op;//信號量在一次操作中需要改變的數據,通常是兩個數,一個是-1,即P(等待)操作,
                    //一個是+1,即V(發送信號)操作。
    short sem_flg;//通常爲SEM_UNDO,使操作系統跟蹤信號,
                    //並在進程沒有釋放該信號量而終止時,操作系統釋放信號量};

3、semctl 函數

該函數用來直接控制信號量信息,它的原型爲:

int semctl(int sem_id, int sem_num, int command, ...);

如果有第四個參數,它通常是一個 union semum 結構,定義如下:

union semun{
    int val;
    struct semid_ds *buf;
    unsigned short *arry;
};

前兩個參數與前面一個函數中的一樣,command 通常是下面兩個值中的其中一個

SETVAL:用來把信號量初始化爲一個已知的值。p 這個值通過 union semun 中的 val 成員設置,其作用是在信號量第一次使用前對它進行設置。

IPC_RMID:用於刪除一個已經無需繼續使用的信號量標識符。

4. 進程使用信號量通信

下面使用一個例子來說明進程間如何使用信號量來進行通信,這個例子是兩個相同的程序同時向屏幕輸出數據,我們可以看到如何使用信號量來使兩個進程協調工作,使同一時間只有一個進程可以向屏幕輸出數據。注意,如果程序是第一次被調用(爲了區分,第一次調用程序時帶一個要輸出到屏幕中的字符作爲一個參數),則需要調用 set_semvalue 函數初始化信號並將 message 字符設置爲傳遞給程序的參數的第一個字符,同時第一個啓動的進程還負責信號量的刪除工作。如果不刪除信號量,它將繼續在系統中存在,即使程序已經退出,它可能在你下次運行此程序時引發問題,而且信號量是一種有限的資源。

在 main 函數中調用 semget 來創建一個信號量,該函數將返回一個信號量標識符,保存於全局變量 sem_id 中,然後以後的函數就使用這個標識符來訪問信號量。

源文件爲 seml.c,代碼如下:

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/sem.h> 

union semun{
  int val;
  struct semid_ds *buf;
  unsigned short *arry;
}; 

static int sem_id = 0;
static int set_semvalue();
static void del_semvalue();
static int semaphore_p();
static int semaphore_v(); 

int main(int argc, char *argv[]){  
  char message = 'X';
  int i = 0;
  //創建信號量  
  sem_id = semget((key_t)1234, 1, 0666 | IPC_CREAT);   
  if(argc > 1)  {   
    //程序第一次被調用,初始化信號量    
    if(!set_semvalue())    {      
      fprintf(stderr, "Failed to initialize semaphore\n");
      exit(EXIT_FAILURE);    
    }
    //設置要輸出到屏幕中的信息,即其參數的第一個字符    
    message = argv[1][0]; 
    sleep(2);
  }
  for(i = 0; i < 10; ++i)  {
    //進入臨界區    
    if(!semaphore_p())
      exit(EXIT_FAILURE);    
    //向屏幕中輸出數據    
    printf("%c", message);    
    //清理緩衝區,然後休眠隨機時間    
    fflush(stdout);    
    sleep(rand() % 3);
    //離開臨界區前再一次向屏幕輸出數據    
    printf("%c", message); 
    fflush(stdout);    
    //離開臨界區,休眠隨機時間後繼續循環    
    if(!semaphore_v())
      exit(EXIT_FAILURE);    
    sleep(rand() % 2);
  }   
  sleep(10); 
  printf("\n%d - finished\n", getpid());   
  if(argc > 1)  {
    //如果程序是第一次被調用,則在退出前刪除信號量    
    sleep(3);
    del_semvalue();  
  }
  exit(EXIT_SUCCESS);
}

static int set_semvalue(){  
  //用於初始化信號量,在使用信號量前必須這樣做  
  union semun sem_union;   
  sem_union.val = 1;
  if(semctl(sem_id, 0, SETVAL, sem_union) == -1)
    return 0;
  return 1;
} 

static void del_semvalue(){  
  //刪除信號量  
  union semun sem_union;   
  if(semctl(sem_id, 0, IPC_RMID, sem_union) == -1)
    fprintf(stderr, "Failed to delete semaphore\n");
}

static int semaphore_p(){  
  //對信號量做減1操作,即等待P(sv)  
  struct sembuf sem_b;
  sem_b.sem_num = 0;
  sem_b.sem_op = -1; //P() 
  sem_b.sem_flg = SEM_UNDO;  
  if(semop(sem_id, &sem_b, 1) == -1) {    
    fprintf(stderr, "semaphore_p failed\n");
    return 0;
  }  
  return 1;
}

static int semaphore_v(){  
  //這是一個釋放操作,它使信號量變爲可用,即發送信號V(sv)  
  struct sembuf sem_b;  
  sem_b.sem_num = 0;  
  sem_b.sem_op = 1;//V()  
  sem_b.sem_flg = SEM_UNDO;  
  if(semop(sem_id, &sem_b, 1) == -1)  {    
    fprintf(stderr, "semaphore_v failed\n");
    return 0;
  }  
  return 1;
}

運行結果如下:

注:這個程序的臨界區爲 main 函數 for 循環不的 semaphore_p 和 semaphore_v 函數中間的代碼。

例子分析 :同時運行一個程序的兩個實例,注意第一次運行時,要加上一個字符作爲參數,例如本例中的字符‘O’,它用於區分是否爲第一次調用,同時這個字符輸出到屏幕中。因爲每個程序都在其進入臨界區後和離開臨界區前打印一個字符,所以每個字符都應該成對出現,正如你看到的上圖的輸出那樣。在 main 函數中循環中我們可以看到,每次進程要訪問 stdout(標準輸出),即要輸出字符時,每次都要檢查信號量是否可用(即 stdout 有沒有正在被其他進程使用)。所以,當一個進程 A 在調用函數 semaphore_p 進入了臨界區,輸出字符後,調用 sleep 時,另一個進程 B 可能想訪問 stdout,但是信號量的 P 請求操作失敗,只能掛起自己的執行,當進程 A 調用函數 semaphore_v 離開了臨界區,進程 B 馬上被恢復執行。然後進程 A 和進程 B 就這樣一直循環了 10 次。

5. 對比例子——進程間的資源競爭

看了上面的例子,你可能還不是很明白,不過沒關係,下面我就以另一個例子來說明一下,它實現的功能與前面的例子一樣,運行方式也一樣,都是兩個相同的進程,同時向 stdout 中輸出字符,只是沒有使用信號量,兩個進程在互相競爭 stdout。它的代碼非常簡單,文件名爲 normalprint.c,代碼如下:

#include <stdio.h>
#include <stdlib.h> 

int main(int argc, char *argv[]){
  char message = 'X';
  int i = 0;
  if(argc > 1)
    message = argv[1][0];
  for(i = 0; i < 10; ++i)  {
    printf("%c", message);
    fflush(stdout);
    sleep(rand() % 3);
    printf("%c", message);
    fflush(stdout);
    sleep(rand() % 2);
  }
  sleep(10);
  printf("\n%d - finished\n", getpid());
  exit(EXIT_SUCCESS);
}

運行結果如下:

例子分析:

從上面的輸出結果,我們可以看到字符‘X’和‘O’並不像前面的例子那樣,總是成對出現,因爲當第一個進程 A 輸出了字符後,調用 sleep 休眠時,另一個進程 B 立即輸出並休眠,而進程 A 醒來時,再繼續執行輸出,同樣的進程 B 也是如此。所以輸出的字符就是不成對的出現。這兩個進程在競爭 stdout 這一共同的資源。通過兩個例子的對比,我想信號量的意義和使用應該比較清楚了。

6、信號量的總結

信號量是一個特殊的變量,程序對其訪問都是原子操作,且只允許對它進行等待(即 P(信號變量)) 和發送(即 V(信號變量)) 信息操作。我們通常通過信號來解決多個進程對同一資源的訪問競爭的問題,使在任一時刻只能有一個執行線程訪問代碼的臨界區域,也可以說它是協調進程間的對同一資源的訪問權,也就是用於同步進程的。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UI0MT5KhPGfhyqRPERCv4g