[apue] 進程環境那些事兒

来源:https://www.cnblogs.com/goodcitizen/archive/2023/08/29/things_about_process_environment.html
-Advertisement-
Play Games

atexit 註冊的處理器中可以再調 atexit 或 exit 嗎?putenv 或 setenv 增加一個環境變數後 environ 指針地址為什麼變了?setjmp & longjmp 跨函數跳轉後自動變數為什麼回退了?設置 RLIMIT_NPROC 為 10 為何連一個子進程也 fork 不... ...


main 函數與進程終止

眾所周知,main 函數為 unix like 系統上可執行文件的"入口",然而這個入口並不是指鏈接器設置的程式起始地址,後者通常是一個啟動常式,它從內核取得命令行參數和環境變數值後,為調用 main 函數做好安排。main 函數原型為:

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

這是 ISO C 和 POSIX.1 指義的,當然還存在下麵幾種不太標準的 main 原型:

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

不帶 argc & argv 參數的表示不打算接受命令行參數;void 返回值的表示不打算返回一個結束狀態。

進程的結束狀態碼與 main 的返回值關係如下:

  • main 聲明為 int 類型返回值
    • main 結束前執行了 return x 語句:x
    • main 結束前執行了無參數 return 語句:未定義 (warning: ‘return’ with no value, in function returning non-void)
    • main 結束前執行了 exit(x) 函數:x
    • main 結束前未執行以上語句:未定義 (warning: control reaches end of non-void function)
    • main 結束前未執行以上語句 [-std=c99]:0
  • main 聲明為 void 類型返回值 (warning: return type of ‘main’ is not ‘int’)
    • main 結束前執行了 return x 語句:未定義 (warning: ‘return’ with a value, in function returning void)
    • main 結束前執行了無參數 return 語句:未定義 
    • main 結束前執行了 exit(x) 函數:x
    • main 結束前未執行以上語句:未定義

測試機為 CentOS 7.9,gcc 版本 4.8.5,每一項的 warning 信息就是基於這兩個版本測得。未定義的場景中,均返回 25 這個魔數。

開了 -std=c99 後大部分場景沒有改善,僅 main 返回值被聲明為 int 類型且在結束前沒有調用任何 return 或 exit 時 (第 1 項第 4 小項) 發生了顯著變化:從未定義變為返回 0。

進程有 8 種終止方式,其中 5 種為正常終止:

  • 從 main 返回 (無論是否有返回值)
  • 調用 exit
  • 調用 _exit 或 _Exit
  • 最後一個線程從其啟動常式返回
  • 最後一個線程調用 pthread_exit

另有 3 種為異常終止:

  • 調用 abort
  • 接到一個信號並終止
  • 最後一個線程對取消請求做出響應

下麵重點看一下 3 個 exit 函數:

#include <unistd.h>
void _exit(int status);

#include <stdlib.h>
void exit(int status);
void _Exit(int status);

聲明差別不大,_exit 與 _Exit 分別是 POSIX.1 與 ISO C 的標準,不過可以將它們視為等價,都直接進入內核。exit 則在它們的基礎上做了一些清理工作,主要包含以下幾個方面:

  • 清理線程局部存儲 (TLS) 信息
  • 按順序調用註冊的終止處理程式
  • 為所有標準 I/O 庫打開的流調用 fclose 函數,這會 flush 緩衝的輸出數據

關於標準 I/O 庫,請參考之前寫的這篇文章:《[apue] 標準 I/O 庫那些事兒 》。

有了上面的鋪墊,可以這樣理解可執行程式的啟動常式與 main 之間的關係:

...
exit (main (argc, argv));

 即 main 的返回值是直接傳遞給 exit 的 status 參數作為進程結束狀態的。

atexit

關於終止處理程式,一般通過 atexit 函數進行註冊:

#include <stdlib.h>
int atexit(void (*function)(void));

這裡的 function 參數就是希望在 exit 時被調用的清理程式,關於終止處理程式,有下麵幾點需要註意:

  • 調用次數有上限,通過 sysconf (_SC_ATEXIT_MAX) 查詢 (實測為 2147483647, 即 INT_MAX)
  • FILO,先註冊的後被調用,類似於堆棧,而非隊列
  • 調用次數等於註冊次數,同一清理程式可多次註冊,註冊幾次調用幾次
  • 執行 exec 函數族執行另一個程式的時候,自動清空 atexit 註冊的清理程式
  • 在清理程式中調用 exit "無效",如果調用 _exit 或 _Exit,會導致程式直接退出,後續清理程式不再被調用
  • 進程異常終止時清理程式不會被調用

下麵這個例子驗證了調用次數與 FILO 特性:

#include "../apue.h"

void do_dirty_work ()
{
  printf ("doing dirty works!\n");
}

void bye ()
{
  printf ("bye, forks~\n");
}

void times ()
{
  static int counter = 32;
  printf ("times %d\n", counter--);
}

int main ()
{
  int ret = 0;
  ret = atexit (do_dirty_work);
  if (ret != 0)
    err_sys ("atexit");

  ret = atexit (bye);
  if (ret != 0)
    err_sys ("bye1");

  ret = atexit (bye);
  if (ret != 0)
    err_sys ("bye2");

  for (int i=0; i<32; i++)
  {
    ret = atexit (times);
    if (ret != 0)
      err_sys ("times");
  }

  printf ("main is done!\n");
  return 0;
}

執行它會有如下輸出:

$ ./atexit
main is done!
times 32
times 31
times 30
times 29
times 28
times 27
times 26
times 25
times 24
times 23
times 22
times 21
times 20
times 19
times 18
times 17
times 16
times 15
times 14
times 13
times 12
times 11
times 10
times 9
times 8
times 7
times 6
times 5
times 4
times 3
times 2
times 1
bye, forks~
bye, forks~
doing dirty works!

在 bye 中增加一些 exit 調用,觀察是否會有變化:

void bye ()
{
  printf ("bye, forks~\n");
  exit (2);  // no effect
  printf ("after exit (2)\n");
}

結果與之前完全一致,不過進程結束狀態變為了 2:

$ ./atexit
main is done!
times 32
...
times 1
bye, forks~
bye, forks~
doing dirty works!
> echo $?
2

可見 exit 並非沒有生效,一個合理的推斷是:第二次進入 exit 後,繼續處理之前沒處理完的清理程式,使得輸出看起來就像"沒生效"一樣。真正的 _exit 是被第二次進入 bye 的那個 exit 所調用,對程式稍加改動來看個明白:

int exit_status = 10;
void bye ()
{
  printf ("bye, forks~\n");
  exit (exit_status++);  // no effect
  printf ("after exit (%d)\n", exit_status-1);
}

為了便於區別,這裡給的初始值為 10,每調用一次 bye,exit_status 遞增 1,如果最後進程結束狀態碼為 10 就證明是第一次 exit 結束了進程,否則就是第二次。

$ ./atexit
main is done!
times 32
...
times 1
bye, forks~
bye, forks~
doing dirty works!
> echo $?
11

結論已經非常明顯,之前的猜測成立!如此就可以合理的推斷 exit 調用清理程式後,會將其從 FILO 結構中移除,從而避免再次調用,進而引發無限迴圈。

下麵試試 _exit 的效果:

void bye ()
{
  printf ("bye, forks~\n");
  _exit (3);  // quit and no other atexit function running anymore !
  printf ("after _exit (3)\n");
}

改為 _exit 後輸出發生了截斷:

$ ./atexit
main is done!
times 32
...
times 1
bye, forks~
$ echo $?
3

進入 bye 處理程式後進程就終止了,後續的處理程式不再調用。檢查進程結束狀態碼為 3,正好是 _exit  的 status 參數。

將上面 exit 和 _exit 全都打開後,_exit 反而不起作用了:

void bye ()
{
  printf ("bye, forks~\n");
  exit (2);  // no effect
  printf ("after exit (2)\n");
  _exit (3);  // no effect
  printf ("after _exit (3)\n");
}

經過上面的分析,想必讀者已經知道了答案,正確的做法是將 _exit 放在 exit 前面,這樣才能避免進入 exit 之後不再返回,從而被忽略。

最後再試一種場景,就是在處理器中繼續調用 atexit 註冊新的處理器,觀察新的處理器是否能被調用,參考下麵這個例子:

#include "../apue.h"

void do_dirty_work ()
{
  printf ("doing dirty works!\n");
}

void bye ()
{
  printf ("bye, forks~\n");
  int ret = atexit (do_dirty_work);
  if (ret != 0)
      err_sys ("do_dirty_work");
}

int main ()
{
  int ret = 0;
  ret = atexit (bye);
  if (ret != 0)
    err_sys ("bye");

  printf ("main is done!\n");
  return 0;
}

先註冊處理器 bye,在其被回調時再註冊處理器 do_dirty_work,結果是兩個處理器都能被回調:

$ ./atexit_term
main is done!
bye, forks~
doing dirty works!

如果註冊的處理器形成迴圈會如何?參考下麵的例子:

#include "../apue.h"

extern void bye ();
void do_dirty_work ()
{
  printf ("doing dirty works!\n");
  int ret = atexit (bye);
  if (ret != 0)
      err_sys ("bye2");
}

void bye ()
{
  printf ("bye, forks~\n");
  int ret = atexit (do_dirty_work);
  if (ret != 0)
      err_sys ("do_dirty_work");
}

int main ()
{
  int ret = 0;
  ret = atexit (bye);
  if (ret != 0)
    err_sys ("bye");

  printf ("main is done!\n");
  return 0;
}

在 do_dirty_work 中再次註冊 bye 作為處理器,重新編譯後運行,發現程式果然陷入了死迴圈:

$ ./atexit_term
main is done!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
...
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!
bye, forks~
doing dirty works!^C

直到輸入 Ctrl+C  才能退出,看起來 atexit 並不能檢測這種情況,需要程式員自己避免調用環的形成,好在這種場景並不多見。

命令行參數與環境變數

ISO C 與 POSIX.1 都要求 argv[argc] 參數為 NULL,因此下麵兩種遍歷命令行參數的方式是等價的:

int i; 
for (i=0; i<argc; ++ i)
...
for (i=0; argv[i]!=NULL; ++i)
...

環境變數也有類似的約定。大多數 unix like 都支持以下的 main 聲明:

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

將環境變數放在 main 第三個參數上,不過標準的 ISO C 和 POSIX.1 不支持,它們規定使用單獨的全局變數訪問環境變數:

extern char **environ; 

由於沒有類似 argc 的參數來說明參數表長度,環境變數的遍歷只能依賴結尾 NULL 的方式。

環境變數

環境變數的內容通常為以下形式:

name=value

name 通常大寫,不過這隻是一種慣例,內核並不檢查環境變數內容,它的解釋完全取決於各個應用程式。例如 PATH 變數可以通過冒號指定多個路徑:

PATH=/home/users/yunhai01/.local/bin:/home/users/yunhai01/bin:/usr/lib64/qt-3.3/bin:/usr/local/bin:/usr/bin:/opt/bin:/home/opt/bin:/usr/local/sbin:/usr/sbin:/opt/bin:/home/opt/bin

ISO C & POSIX.1 定義了一組處理環境變數的函數:

#include <stdlib.h>

[ISO C/POSIX.1] char *getenv(const char *name);
[POSIX.1]       int setenv(const char *name, const char *value, int overwrite);
[POSIX.1]       int unsetenv(const char *name);
[XSI]           int putenv(char *string);
[linux]         int clearenv(void);

它們屬於的標準在函數聲明前做了標識。其中:

  • getenv 根據 name 參數查找變數並獲取 value 部分返回給用戶
  • setenv 根據 name 參數查找變數
    • 變數不存在,直接設置新的變數 name=value
    • 變數存在
      • overwrite == 0:保留原有變數不變,返回 0
      • overwrite != 0:刪除原有變數,設置新的變數 name=value
  • unsetenv 刪除 name 的定義,不存在也返回 0
  • putenv 與 setenv 類似,不同的是
    • string 參數本身是 name=value 的組合體
    • 變數存在時刪除,沒有標誌位可以控制覆蓋行為
    • setenv 需要分配新的存儲區,因此不要求用戶為 name & value 參數分配存儲空間;putenv 則必需由用戶分配
  • clearenv 是 linux 平臺的專有擴展,用於清空環境變數

關於增刪改環境變數導致的空間變化問題,下一節詳細說明。

最後需要說明的是,對環境變數的更改只對當前進程及之後啟動的子進程生效,不對父進程及之前啟動的子進程產生影響。

存儲空間佈局

直接上圖:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

上面是一種典型的記憶體排布,只是舉個例子,並不代表所有平臺和架構都以此這種方式安排其存儲空間,圖中的記憶體地址更是以 Linux x86 處理器為例的。

其中:

  • 代碼段也稱正文段,存儲可執行程式的機器指令部分,一般是只讀、共用的
  • 初始化數據段也稱為數據段,包含了程式中明確賦初值的全局或靜態變數,以上兩段從程式文件中讀入
  • 非初始化數據段也稱為 bss (block started by symbol),沒有初始化的全局或靜態變數包含在這個段中。由於不需要保存初始化值,程式文件中甚至沒有這個段,它是由 exec 初始化為 0 的
  • 堆,動態存儲分配區域,由低地址向高地址增長
  • 棧,自動變數及函數調用所需的信息存放在此段。一個函數調用實例中的變數不會影響另一個函數調用實例中的變數。由高地址向低地址增長
  • 命令行參數與環境變數存放在棧底以上的空間

其中除堆和棧外,其它段都變化很小或不變,所以設置堆和棧對向增長是非常聰明的做法。當向下增長的棧與向上增長的堆相遇時,進程的地址空間就用光了。

下麵的程式驗證了 C 程式的記憶體佈局:

#include "../apue.h"

int data1 = 2;
int data2 = 3;
int data3;
int data4;

int main (int argc, char *argv[])
{
  char buf1[1024] = { 0 };
  char buf2[1024] = { 0 };
  char *buf3 = malloc(1024);
  char *buf4 = malloc(1024);
  printf ("onstack %p, %p\n",
    buf1,
    buf2);

  extern char ** environ;
  printf ("env %p\n", environ);
  printf ("arg %p\n", argv);

  printf ("onheap %p, %p\n",
    buf3,
    buf4);

  free (buf3);
  free (buf4);

  printf ("on bss %p, %p\n",
    &data3,
    &data4);

  printf ("on init %p, %p\n",
    &data1,
    &data2);

  printf ("on code %p\n", main);
  return 0;
}

在 linux 上編譯運行:

$ ./layout
onstack 0x7ffe31b752a0, 0x7ffe31b74ea0
env 0x7ffe31b757b8
arg 0x7ffe31b757a8
onheap 0x1984010, 0x1984420
on bss 0x6066b8, 0x6066bc
on init 0x606224, 0x606228
on code 0x40179d

雖然具體地址和書上講的有出入,但是總體佈局確實是 code -> init -> bss -> heap -> stack -> env / arg 的順序沒錯。

size

size 命令用於報告可執行文件的 code/data/bss 段的長度:

$ size ./layout ./layout_s /bin/sh
   text	   data	    bss	    dec	    hex	filename
  20073	   2152	     80	  22305	   5721	./layout
 802535	   7292	  11120	 820947	  c86d3	./layout_s
 905942	  36000	  22920	 964862	  eb8fe	/bin/sh

dec/hex 列分別是三者加總後的十進位與十六進位長度。示例中 layout_s 是靜態鏈接版本,可見使用共用庫的動態鏈接在各個段的尺寸上都有明顯縮減。

堆分配

棧的增長主要依賴函數調用層次的增加;堆的增長主要依賴以下存儲器分配函數:

#include <stdlib.h>

void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);

void free(void *ptr);

其中:

  • malloc 分配 size 長度的存儲區
  • calloc 分配 nmemb*size 長度的存儲區
  • realloc 可更改以前分配區到 size 長度 (增加或減小)

對於新增的存儲區

  • calloc 初始值為 0
  • malloc 和 realloc 初始值不確定

對於 realloc,新舊地址之間的關係:

  • 當存儲區減小時,新舊地址保持一致
  • 當存儲區增加時
    • 原存儲區後有足夠的空間時,新舊地址保持一致
    • 原存儲區後沒有足夠的空間,新舊地址不同,會先分配足夠大的空間,複製數據,再釋放原存儲區

realloc(NULL, size) 等價於 malloc(size)。

sbrk

這些分配常式通常用 sbrk 系統調用來擴充進程的堆:

#include <unistd.h>
void *sbrk(intptr_t increment);

這通常是通過調用 program break 的位置來實現的,參考 man 這段說明:

DESCRIPTION
       brk()  and  sbrk() change the location of the program break, which defines the end of the process's data segment (i.e., the pro‐
       gram break is the first location after the end of the uninitialized data segment).  Increasing the program break has the  effect
       of allocating memory to the process; decreasing the break deallocates memory.

program break 就是 bss 段的結尾,參考上圖應該就是堆底。

sbrk 也可以減小堆大小,不過大多數 malloc 和 free 的實現都不減小進程的存儲空間,釋放的空間可供以後再分配,但通常將它們保持在 malloc 池中而不返回給內核。

環境變數空間的變更

有上面內容的鋪墊,就可以回顧下上一節中增刪改環境變數對存儲空間的影響了:

  • 刪除環境變數,之後的變數前移填補刪除後的空位
  • 修改環境變數
    • 新值長度小於等於舊值,在原字元串空間中寫入新值
    • 新值長度大於舊值,在堆上分配新字元串空間並賦值,更新環境變數表中的指針使之指向新分配的字元串
  • 新增環境變數
    • 第一次新增環境變數,在堆上分配新的環境變數表,將原來的環境變數"複製"到新分配的環境變數表中,然後把新增的環境變數字元串放在表尾,再新增一個空指針放在最後,最後使用 environ 變數指向新分配的環境變數表,基本上就是將環境變數從棧頂搬到了堆中,不過大多數環境變數仍指向棧頂中分配的字元串而已
    • 非第一次新增,使用 realloc 重新分配 environ 變數,以容納新增加的環境變數

環境變數空間改變如此複雜,主要是因為它的大小被棧頂限制死了,沒有辦法擴容,當增加環境變數數目時,只能從棧頂搬到堆中。

下麵的程式演示了這一過程:

#include "../apue.h"

void print_envs()
{
  extern char **environ;
  printf ("base %p\n", environ);
  for (int i=0; environ && environ[i] != 0; ++ i)
  {
    printf ("[%p]  %s\n", environ[i], environ[i]);
  }
}

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

  setenv ("HOME", "ME", 1);
  printf ("\nafter set HOME:\n");
  print_envs ();

  setenv ("LOGNAME", "this is a very very long user name", 1);
  printf ("\nafter set LOGNAME:\n");
  print_envs ();

  unsetenv ("PATH");
  printf ("\nafter unset PATH:\n");
  print_envs ();

  setenv ("DISAPPEAR", "not exist before", 0);
  printf ("\nafter set DISAPPEAR:\n");
  print_envs ();

  setenv ("ADDISION", "addision adding", 0);
  printf ("\nafter set ADDISION:\n");
  print_envs ();

  return 0;
}

程式比較簡單,依次執行以下操作:add HOME -> add LOGNAME -> remove PATH -> add DISAPPEAR -> add ADDISION,每次操作後都列印整個環境變數表,以觀察 environ 和各個環境變數的變化:

$ ./envpos
base 0x7fff15e16468
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17c5f]  PATH=/home/yunh/.BCloud/bin:/home/users/yunhai01/.local/bin:/home/users/yunhai01/bin:/home/users/yunhai01/tools/bin:/home/users/yunhai01/project/android-ndk-r20:/usr/lib64/qt-3.3/bin:/usr/local/bin:/usr/bin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin:/usr/local/sbin:/usr/sbin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x7fff15e17e58]  HOME=/home/users/yunhai01
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x7fff15e17e8a]  LOGNAME=yunhai01
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos

啟動後先列印整個環境變數表,大概有 30 個環境變數。

after set HOME:
base 0x7fff15e16468
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17c5f]  PATH=/home/yunh/.BCloud/bin:/home/users/yunhai01/.local/bin:/home/users/yunhai01/bin:/home/users/yunhai01/tools/bin:/home/users/yunhai01/project/android-ndk-r20:/usr/lib64/qt-3.3/bin:/usr/local/bin:/usr/bin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin:/usr/local/sbin:/usr/sbin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x16fc010]  HOME=ME
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x7fff15e17e8a]  LOGNAME=yunhai01
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos

設置 HOME 變數,雖然新值長度小於舊值,這裡仍然為新值在堆上分配了空間,看起來 linux 上的實現偷懶了。

after set LOGNAME:
base 0x7fff15e16468
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17c5f]  PATH=/home/yunh/.BCloud/bin:/home/users/yunhai01/.local/bin:/home/users/yunhai01/bin:/home/users/yunhai01/tools/bin:/home/users/yunhai01/project/android-ndk-r20:/usr/lib64/qt-3.3/bin:/usr/local/bin:/usr/bin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin:/usr/local/sbin:/usr/sbin:/opt/bin:/home/opt/bin:/home/users/yunhai01/tools/node-v14.17.0-linux-x64/bin
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x16fc010]  HOME=ME
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x16fc060]  LOGNAME=this is a very very long user name
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos

設置 LOGNAME 變數,新值長度大於舊值,這裡沒有懸念的在堆上進行了分配。

after unset PATH:
base 0x7fff15e16468
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x16fc010]  HOME=ME
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x16fc060]  LOGNAME=this is a very very long user name
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos

刪除 PATH 變數,這一步主要驗證再次新增環境變數時,會不會重覆利用已刪除的空位,到目前為止 environ 指針地址 (0x7fff15e16468) 沒有發生變化,仍位於棧頂之上。

after set DISAPPEAR:
base 0x16fc0d0
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x16fc010]  HOME=ME
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x16fc060]  LOGNAME=this is a very very long user name
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos
[0x16fc1e0]  DISAPPEAR=not exist before

增加 DISAPPEAR 變數,沒有懸念的在堆上分配了空間,最大的變化在於 environ 指針變了!從棧頂之上移動了到了堆中 (0x16fc0d0),看起來之前刪除 PATH 變數騰空的位置沒有利用上。

after set ADDISION:
base 0x16fc0d0
[0x7fff15e17488]  XDG_SESSION_ID=318004
[0x7fff15e1749e]  HOSTNAME=yunhai.bcc-bdbl.baidu.com
[0x7fff15e174c1]  SHELL=/bin/bash
[0x7fff15e174d1]  TERM=xterm-256color
[0x7fff15e174e5]  HISTSIZE=1000
[0x7fff15e174f3]  SSH_CLIENT=172.31.23.41 52661 22
[0x7fff15e17514]  ANDROID_SDK_ROOT=/home/users/yunhai01/android_sdk
[0x7fff15e17546]  QTDIR=/usr/lib64/qt-3.3
[0x7fff15e1755e]  QTINC=/usr/lib64/qt-3.3/include
[0x7fff15e1757e]  SSH_TTY=/dev/pts/6
[0x7fff15e17591]  USER=yunhai01
[0x7fff15e17c57]  TMOUT=0
[0x7fff15e17de4]  MAIL=/var/spool/mail/yunhai01
[0x7fff15e17e02]  PWD=/home/users/yunhai01/code/apue/07.chapter
[0x7fff15e17e30]  LANG=en_US.UTF-8
[0x7fff15e17e41]  HISTCONTROL=ignoredups
[0x16fc010]  HOME=ME
[0x7fff15e17e72]  SHLVL=2
[0x7fff15e17e7a]  GTAGSFORCECPP=1
[0x16fc060]  LOGNAME=this is a very very long user name
[0x7fff15e17e9b]  QTLIB=/usr/lib64/qt-3.3/lib
[0x7fff15e17eb7]  SSH_CONNECTION=172.31.23.41 52661 10.138.62.136 22
[0x7fff15e17eea]  LESSOPEN=||/usr/bin/lesspipe.sh %s
[0x7fff15e17f0d]  ANDROID_NDK_HOME=/home/users/yunhai01/project/android-ndk-r20
[0x7fff15e17f4b]  XDG_RUNTIME_DIR=/run/user/383278
[0x7fff15e17f6c]  LLVM_HOME=/home/users/yunhai01/project/ndk-llvm
[0x7fff15e17f9c]  HISTTIMEFORMAT=%Y-%m-%d %H:%M:%S
[0x7fff15e17fbe]  OLDPWD=/home/users/yunhai01/code/apue
[0x7fff15e17fe4]  _=./envpos
[0x16fc1e0]  DISAPPEAR=not exist before
[0x16fc240]  ADDISION=addision adding

增加 ADDISION 變數,仍然在堆上分配空間,而且 environ 指針地址 (0x16fc0d0) 沒有發生變化,看起來仍有足夠的空間讓 realloc 分配。

指令跳轉 (setjmp & longjmp)

說到指令跳轉,第一印象就是 goto。由於程式的執行本質是一條條機器代碼的執行,有些指令本身自帶跳轉屬性,像函數調用 (call)、函數返回 (return) 、switch-case 都是某種形式的指令跳轉,goto 則將這種能力公佈給了開發者,然而下麵的兩個限制導致它在實際應用上的推廣受阻:

  • 只能在函數內部跳轉,無法跨越函數棧
  • 濫用 goto 導致代碼邏輯不清晰、後期維護困難

setjmp & longjmp 完美的解決了上述 goto 的缺點,支持跨函數棧的跳轉、且使用上更不易被濫用,也被稱為非局部 goto。

它的跳轉邏輯和現代 C++ 的異常機制已經非常相似了,區別是後者加入了對棧上對象析構函數的自動調用等更多的內容。

先來看函數原型:

#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

書上給的例子就不錯,這裡找到另外一個更簡單的例子:

#include <setjmp.h>
#include <stdio.h>
 
static jmp_buf g_jmpbuf;
 
void exception_jmp()
{
    printf ("throw_exception_jmp start.\n");
    longjmp(g_jmpbuf, 1);
    printf ("throw_exception_jmp end.\n");
}
 
void call_jmp()
{
    exception_jmp();
}
 
int main(int argc, char *argv[])
{
    /* using setjmp and longjmp */
    if (setjmp(g_jmpbuf) == 0)
    {
        call_jmp();
    }
    else
    {
        printf ("catch exception via setimp-longjmp.\n");
    }
 
    return 0;
}

編譯運行這個 demo,輸出如下:

throw_exception_jmp start.
catch exception via setimp-longjmp.

對於沒有接觸過非局部 goto 的人來說還是比較直觀的。

compiler explorer

這裡推薦一個線上的 c++ 編譯器 compiler explorer,對於沒有 Linux 環境的人來說非常友好,下麵是編譯運行上述 demo 的過程:

可以看到這個工具非常強大,可以:

  • 選擇編譯語言
  • 選擇編譯器
  • 選擇編譯模式 (是否開啟 Vim)
  • 修改編譯鏈接選項
  • 查看反彙編
  • 查看預處理結果
  • 查看運行輸出
  • 更改視窗佈局

有興趣的讀者可以自行探索。

回歸代碼,註意 longjmp 第二個參數,這個不是隨便給的,它將作為跳轉後 setjmp 的返回值,要與初始化時返回的 0 有一些區別,另外允許任意多個 longjmp 跳向同一個 jmp_buf 實例,這種情況下,通過指定不同的 val 參數也能區別出跳轉源,是不是想的很周到?

longjmp 跳轉時,當前所在的函數棧到 setjmp 之間的棧將被回收,依附之上的自動變數將不復存在,但是跳轉目的地所在的棧幀還是存在的,此外還有不在當前棧上的全局變數、靜態變數等等也是存在的。

變數值回退

雖然沒讀過 setjmp & longjmp 的源碼,但原理應該就是存儲和恢復函數棧 (各種寄存器),那這些未被撤銷的變數,是恢復到 setjmp 時的狀態,還是保留最後的狀態呢?對上面的例子稍加修改來進行一番考察:

#include <setjmp.h>
#include <stdio.h>
 
static jmp_buf g_jmpbuf;
static int globval; 
 
void exception_jmp()
{
    printf ("throw_exception_jmp start.\n");
    longjmp(g_jmpbuf, 1);
    printf ("throw_exception_jmp end.\n");
}
 
void call_jmp(int i, int j, int k, int l)
{
    printf ("in call_jmp (): \n"
        "globval = %d,\n"
        "autoval = %d,\n"
        "regival = %d,\n"
        "volaval = %d,\n"
        "statval = %d\n\n", 
        globval, 
        i, 
        j, 
        k, 
        l); 
    exception_jmp();
}
 
int main(int argc, char *argv[])
{
    int autoval; 
    register int regival; 
    volatile int volaval; 
    static int statval; 
    globval = 1; 
    autoval = 2; 
    regival = 3; 
    volaval = 4; 
    statval = 5; 
    /* using setjmp and longjmp */
    if (setjmp(g_jmpbuf) == 0)
    {
        /*
        * Change variables after setjmp, but before longjmp
        */
        globval = 95;  
        autoval = 96; 
        regival = 97; 
        volaval = 98; 
        statval = 99; 
        call_jmp(autoval, regival, volaval, statval);
    }
    else
    {
        printf ("catch exception via setimp-longjmp.\n");
        printf ("in main (): \n"
        "globval = %d,\n"
        "autoval = %d,\n"
        "regival = %d,\n"
        "volaval = %d,\n"
        "statval = %d\n\n", 
        globval, 
        autoval, 
        regival, 
        volaval, 
        statval); 
    }
 
    return 0;
}

在原來的基礎上添加了幾種類型的變數:

  • globaval:全局變數
  • autoval:main 棧上自動變數
  • regival:main 棧上寄存器變數
  • valaval:main 棧上易失變數
  • statval:main 棧上靜態變數

並分別在 call_jmp 內部和 longjmp 後 (第二次從 setjmp 返回) 時列印它們的值:

$  ./jumpvar
in call_jmp ():
globval = 95,
autoval = 96,
regival = 97,
volaval = 98,
statval = 99

throw_exception_jmp start.
catch exception via setimp-longjmp.
in main ():
globval = 95,
autoval = 96,
regival = 97,
volaval = 98,
statval = 99

在沒開優化的情況下,各個變數都最新的狀態,沒有發生值回退現象,添加 -O 編譯選項: 

...
jumpvar: jumpvar.o apue.o
	gcc -Wall -g $^ -o $@

jumpvar.o: jumpvar.c ../apue.h
	gcc -Wall -g -c $< -o $@ -std=c99
    
jumpvar_opt: jumpvar_opt.o apue.o
	gcc -Wall -g $^ -o $@

jumpvar_opt.o: jumpvar.c ../apue.h
	gcc -Wall -g -c $< -o $@ -std=c99 -O
...

再次運行:

$ ./jumpvar
in call_jmp ():
globval = 95,
autoval = 96,
regival = 97,
volaval = 98,
statval = 99

throw_exception_jmp start.
catch exception via setimp-longjmp.
in main ():
globval = 95,
autoval = 2,
regival = 3,
volaval = 98,
statval = 99

這次 autoval 和 regival 的值發生了回退。加優化選項後為提高程式運行效率,這些變數的值從記憶體提升到了寄存器,從而導致恢復 main 堆棧時被一併恢復了。這裡有幾個值得註意的點:

  • 聲明為 register 的 regival 在未開啟優化前編譯器並沒有遵循指令將其放置在寄存器,再一次證實了 register 關鍵字只是建議而非強制
  • 開優化後,棧上的自動變數也被放置在了寄存器中
  • 即使開優化,volatile 關鍵字聲明的變數也不存在於寄存器中

所以最終的結論是:如果不想棧上的變數受 setjmp & longjmp 影響發生值回退,最好將它們聲明為 volatile

這裡出於好奇,也使用 compiler explorer 運行了一把,結果沒加優化的第一次運行輸出就不一樣:

 

主要區別在於 regival 會回退,將 compiler explorer 中的 gcc 版本降到和我本地一樣的 4.8.5 後輸出就一致了,因此主要區別在於編譯器版本。

這一方面展示了 compiler explorer 強大的切換編譯器版本的能力,另一方面也顯示高版本 gcc 版本器傾向於"相信"用戶提供的 register 關鍵字。

最後在 compiler explorer 中增加 -O 編譯器參數,會得到和之前一樣的結果:

資源限制 (getrlimit & setrlimit)

進程對系統資源的請求並不是沒有上限的,使用 getrlimit 和 setrlimit 查詢或更改它們:

#include <sys/resource.h>

// struct rlimit {
//     rlim_t rlim_cur;  /* Soft limit */
//     rlim_t rlim_max;  /* Hard limit (ceiling for rlim_cur) */
// };

int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

resource 指定了限制的類型,rlim 則包含了資源限制的信息,主要包含兩個成員:

  • rlim_cur:軟限制值,當前生效的限制值
  • rlim_max:硬限制值,大於等於軟限制值,軟限制值的提升上限
    • 任何用戶可以降低硬限制值,只有超級用戶可以提升硬限制值
    • 每次降低的硬限制值必需大於等於軟限制值

RLIM_INFINITY 表示無限量:

# define RLIM_INFINITY ((__rlim_t) -1)

可以指定的資源限制類型及在本地環境上的軟硬限制值列表如下:

resource  含義 軟限制 硬限制
RLIMIT_AS 進程可用存儲區的最大位元組長度,會影響 sbrk & mmap 函數,非 Linux 平臺也命名為 RLIMIT_VMEM infinite infinite
RLIMIT_CORE 崩潰轉儲文件的最大位元組數,0 表示阻止創建,生成的 core 文件大於限制值時會被截斷 0 infinite
RLIMIT_CPU CPU 的最大量值,單位秒,超過軟限制時,向進程發送 SIGXCPU 信號;超過硬限制時,向進程發送 SIGKILL 信號 infinite infinite
RLIMIT_DATA 數據段的最大位元組長度,是 init + bss + heap 的總長度,即除棧、環境變數、命令行參數外的記憶體總長度 infinite infinite
RLIMIT_FSIZE 可以創建的文件的最大位元組長度,當超過軟限制時,向進程發送 SIGXFSZ 信號,若信號被捕獲,則 write 返回 EBIG 錯誤 infinite infinite
RLIMIT_LOCKS 一個進程可持有的文件鎖的最大數量 (僅 Linux 支持) infinite infinite
RLMIT_MEMLOCK 一個進程使用 mlock 能夠鎖定在存儲器中的最大位元組長度,當超過軟限制時,mlock 返回 ENOMEM 錯誤 65536 65536
RLIMIT_NOFILE 每個進程能打開的最大文件數,當超過軟限制時,open 返回 EMFILE 錯誤,更改軟限制會影響 sysconf (_SC_OPEN_MAX) 返回的值 1024 4096
RLIMIT_NPROC 每個實際用戶 ID 可擁有的最大進程數,當超過軟限制時,fork 返回 EAGAGIN 錯誤,更改軟限制會影響 sysconf (_SC_CHILD_MAX) 返回的值 4096 63459
RLIMIT_RSS 最大駐記憶體集的位元組長度 (resident set size in bytes),如果物理記憶體不足,內核將從進程處取回超過 RSS 的部分 infinite infinite
RLMIT_SBSIZE 用戶任意給定時刻可以占用的套接字緩衝區的最大位元組長度 (僅 FreeBSD 支持) n/a n/a
RLMIT_STACK 棧的最大位元組長度 8388608 infinite

限制值獲取的 demo 就直接用書上提供的,感興趣的讀者可以查看原書,這裡就不再列出了。

進程的資源限制通常是在系統初始化時由進程 0 建立的,然後由每個後續進程繼承,對於其中非 RLIM_INFINITY 限制值的,進程終其一生無法提升限制值 (超級用戶進程除外)。

shell 也提供相應的內置命令 (一般為 ulimit) 來修改預設的限制值,在啟動命令前設置各種限制值才能在新進程中生效,在 CentOS 上使用 -a 選項可以查看所有的限制值:

$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 63459
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 4096
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

大部分限制值與調用介面的 demo 列印的一致,但是單位可能和介面不同,使用時需要註意。

下麵大體按上表的順序對各個限制類型分別施加資源限制,觀察程式的行為是否和預期一致。

RLIMIT_AS (RLIMIT_VMEM)

#include "../apue.h"
#include <sys/resource.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 1024 * 1024;
  lmt.rlim_max = RLIM_INFINITY;
  ret = setrlimit (RLIMIT_AS, &lmt);
  if (ret == -1)
    err_sys ("set rlimit as failed");
    
  char *ptr = malloc (1024 * 1024);
  if (ptr == NULL)
    err_sys ("malloc failed");
    
  printf ("alloc 1 MB success!\n");
  free (ptr);
}

設置進程記憶體軟限制 1M ,然後分配 1M 的堆記憶體:

$ ./lmt_as
malloc failed: Cannot allocate memory

果然記憶體超限失敗了。

RLIMIT_DATA

例子同上,只需將 RLIMIT_AS 修改為 RLIMIT_DATA 即可,輸出也一致。

畢竟 RLIMIT_DATA 所包含的三個段 (init / bss / heap) 中有堆記憶體,通過分配堆記憶體肯定是會擠占這部分限制的。

RLIMIT_CORE

#include "../apue.h"
#include <sys/resource.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 1024;
  lmt.rlim_max = 102400;
  ret = setrlimit (RLIMIT_CORE, &lmt);
  if (ret == -1)
    err_sys ("set rlimit core failed");
    
   char *ptr = 0;
   *ptr = 1;
   return 0;
}

設置崩潰轉儲文件軟限製為 1K,在遭遇空指針崩潰後,能正常生成 core 文件:

$ ./lmt_core
Segmentation fault (core dumped)
$ ls -l core.22482
-rw------- 1 yunhai01 DOORGOD 1024 Aug 27 21:59 core.22482

文件大小未超過 1K。當然前提是需要通過 ulimit -c 指定一個大於 1K 的數值 (非 root 用戶),否則在 setrlimit 時會報錯:

$ ./lmt_core
set rlimit core failed: Operation not permitted

另外生成的 core 文件應該是被截斷了,通過 gdb 載入過程日誌可以判斷:

$ gdb --core=./core.22482
GNU gdb (GDB) Red Hat Enterprise Linux 7.6.1-120.el7
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
"/home/users/yunhai01/code/apue/07.chapter/./core.22482" is not a core dump: File truncated
(gdb)

因此也是不能用的。最後補充一點,設置 core 文件的最小尺寸必需大於 1,否則不會生成任何 core 文件。

RLIMIT_CPU

#include "../apue.h"
#include <sys/resource.h>

void sigxcpu_handler (int sig)
{
  printf ("ate SIGXCPU...\n");
  signal (SIGXCPU, sigxcpu_handler);
}

int main (int argc, char *argv[])
{
  int ret = 0;
  signal (SIGXCPU, sigxcpu_handler);
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 1;
  lmt.rlim_max = 5;
  ret = setrlimit (RLIMIT_CPU, &lmt);
  if (ret == -1)
    err_sys ("set rlimit cpu failed");

  int i = 1, j = 1;
  while (1)
  {
    i *= j++;
  }
  return 0;
}

設置了 CPU 軟限製為 1 秒,硬限製為 5 秒,且捕獲 SIGXCPU 信號,之後進入一個計算死迴圈,不停消耗 CPU 時間:

$ ./lmt_cpu
ate SIGXCPU...
ate SIGXCPU...
ate SIGXCPU...
ate SIGXCPU...
Killed

日誌幾乎是一秒輸出一行,第 5 秒時達到 CPU 硬限制,進程被強制殺死。

RLIMIT_FSIZE

#include "../apue.h"
#include <sys/resource.h>
#include <sys/file.h>

void sigxfsz_handler (int sig)
{
  printf ("ate SIGXFSZ...\n");
  signal (SIGXFSZ, sigxfsz_handler);
}

int main (int argc, char *argv[])
{
  int ret = 0;
  signal (SIGXFSZ, sigxfsz_handler);

  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 1024;
  lmt.rlim_max = RLIM_INFINITY;
  ret = setrlimit (RLIMIT_FSIZE, &lmt);
  if (ret == -1)
    err_sys ("set rlimit fsize failed");

  int fd = open ("core.tmp", O_RDWR | O_CREAT, 0644);
  if (fd == -1)
    err_sys ("open file failed");

  char buf[16];
  int total = 0;
  while (1)
  {
    ret = write (fd, buf, 16);
    if (ret == -1)
      err_sys ("write failed");

    total += ret;
    printf ("write %d, total %d\n", ret, total);
  }

  close (fd);
  return 0;
}

設置最大寫入文件位元組數軟限制 1K,捕獲 SIGXFZE 信號後打開 core.tmp 文件不停寫入,每次寫入 32 位元組直到失敗:

$ ./lmt_fsize
write 32, total 32
write 32, total 64
write 32, total 96
write 32, total 128
write 32, total 160
write 32, total 192
write 32, total 224
write 32, total 256
write 32, total 288
write 32, total 320
write 32, total 352
write 32, total 384
write 32, total 416
write 32, total 448
write 32, total 480
write 32, total 512
write 32, total 544
write 32, total 576
write 32, total 608
write 32, total 640
write 32, total 672
write 32, total 704
write 32, total 736
write 32, total 768
write 32, total 800
write 32, total 832
write 32, total 864
write 32, total 896
write 32, total 928
write 32, total 960
write 32, total 992
write 32, total 1024
ate SIGXFSZ...
write failed: File too large

寫滿 1K 後收到了 SIGXFSZ 信號,捕獲信號避免了進程 abort,不過 write 返回了 EBIG 錯誤。

這裡需要註意不應使用 fopen/fclose/fwrite 來進行測試,因標準 I/O 庫的緩存機制,導致寫入的位元組數大於實際落盤的位元組數,從而得不到準確的限制值。

RLMIT_MEMLOCK

#include "../apue.h"
#include <errno.h>
#include <sys/resource.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 32 * 1024;
  lmt.rlim_max = 64 * 1024;
  ret = setrlimit (RLIMIT_MEMLOCK, &lmt);
  if (ret == -1)
    err_sys ("set rlimit memlock failed");
  
  char *ptr = malloc (32 * 1024);
  if (ptr == NULL)
    err_sys ("malloc failed");

  printf ("alloc 32K success!\n");
#define BLOCK_NUM 32
  for (int i=0; i<BLOCK_NUM; ++ i)
  {
      ret = mlock (ptr + 1024 * i, 1024);
      if (ret == -1)
          err_sys ("mlock failed, %d", errno);

      printf ("lock 1 KB success!\n");
  }
  for (int i=0; i<BLOCK_NUM; ++ i)
  {
      ret = munlock (ptr + 1024 * i, 1024);
      if (ret == -1)
          err_sys ("munlock failed, %d", errno);

      printf ("unlock 1 KB success!\n");
  }
  free (ptr);
  return 0;
}

程式設置記憶體鎖總長度軟限製為 32K,硬限制 64K,分配 32K 記憶體後,在該記憶體上施加 32 個 1K 的範圍鎖:

$ ./lmt_memlock
alloc 32K success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
mlock failed, 12: Cannot allocate memory

在達到 27K 左右時 mlock 報錯了,沒有達到 32K 的上限可能和 glibc 內部也有一些 mlock 調用有關。

如果將 1K 的塊調整為 16 個,總的鎖長度調整為 16K,再次運行 demo:

$ ./lmt_memlock
alloc 32K success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
lock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!
unlock 1 KB success!

這回能成功。最後需要註意的是,預設 memlock 的上限是 64K,如果需要測試大於 64K 的場景,需要提前使用 ulimit 提升該限制。

RLIMIT_NOFILE

#include "../apue.h"
#include <sys/resource.h>
#include <sys/file.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 5;
  lmt.rlim_max = 10;
  ret = setrlimit (RLIMIT_NOFILE, &lmt);
  if (ret == -1)
    err_sys ("set rlimit nofile failed");

  ret = sysconf (_SC_OPEN_MAX);
  printf ("sysconf (_SC_OPEN_MAX) = %d\n", ret);

#define FD_SIZE 10
  char filename[256] = { 0 };
  int fds[FD_SIZE] = { 0 };
  for (int i=0; i<FD_SIZE; ++ i)
  {
    sprintf (filename, "core.%02d.lck", i+1);
    fds[i] = open (filename, O_RDWR | O_CREAT, 0644);
    if (fds[i] == -1)
      err_sys ("open file failed");

    printf ("open file %s\n", filename);
  }

  for (int i=0; i<FD_SIZE; ++ i)
  {
    if (fds[i] != 0)
    {
      close (fds[i]);
    }
  }
  return 0;
}

設置打開文件數軟限製為 5,硬限製為 10,之後創建 10 個文件 (core.xx.lck):

$ ./lmt_nofile
sysconf (_SC_OPEN_MAX) = 5
open file core.01.lck
open file core.02.lck
open file failed: Too many open files

在打開第 3 個文件時失敗,open 返回 EMFILE,這是由於程式本身的 stdin/stdout/stderr 會占用 3 個文件句柄,導致只剩下 2 個指標了。

值得註意的是在設置軟限制後,sysconf 對應的返回值也變為 5 了。

RLIMIT_LOCKS

#include "../apue.h"
#include <sys/resource.h>
#include <sys/file.h>

void sigxfsz_handler (int sig)
{
  printf ("ate SIGXFSZ...\n");
  signal (SIGXFSZ, sigxfsz_handler);
}

int main (int argc, char *argv[])
{
  int ret = 0;
  signal (SIGXFSZ, sigxfsz_handler);

  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 1;
  lmt.rlim_max = 1;
  ret = setrlimit (RLIMIT_LOCKS, &lmt);
  if (ret == -1)
    err_sys ("set rlimit locks failed");

#define FD_SIZE 10
  char filename[256] = { 0 };
  int fds[FD_SIZE] = { 0 };
  for (int i=0; i<FD_SIZE; ++ i)
  {
    sprintf (filename, "core.%02d.lck", i+1);
    fds[i] = open (filename, O_RDWR | O_CREAT, 0644);
    if (fds[i] == -1)
      err_sys ("open file failed");

    ret = flock (fds[i], LOCK_EX /*| LOCK_NB | LOCK_SH*/);
    if (ret == -1)
      err_sys ("lock file failed");

    printf ("establish lock %2d OK\n", i+1);
  }

  for (int i=0; i<FD_SIZE; ++ i)
  {
    if (fds[i] != 0)
    {
      //flock (fds[i], LOCK_UN);
      close (fds[i]);
    }
  }
  return 0;
}

在上一小節例子的基礎上修改:設置文件鎖數量軟硬限制均為 1,在創建文件後為每個文件施加一個文件鎖:

$ ./lmt_locks
establish lock  1 OK
establish lock  2 OK
establish lock  3 OK
establish lock  4 OK
establish lock  5 OK
establish lock  6 OK
establish lock  7 OK
establish lock  8 OK
establish lock  9 OK
establish lock 10 OK
$ ls -lh core.*
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.01.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.02.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.03.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.04.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.05.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.06.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.07.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.08.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.09.lck
-rw-r--r-- 1 yunhai01 DOORGOD    0 Aug 27 22:26 core.10.lck

看起來沒有生效,不清楚是否和文件長度為零有關,但是 flock 介面確實返回了成功,有功夫再研究。

RLIMIT_NPROC

#include "../apue.h"
#include <sys/resource.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  lmt.rlim_cur = 10;
  lmt.rlim_max = 10;
  ret = setrlimit (RLIMIT_NPROC, &lmt);
  if (ret == -1)
    err_sys ("set rlimit nproc failed");

#define PROC_SIZE 10
  int pids[PROC_SIZE] = { 0 };
  for (int i=0; i<PROC_SIZE; ++ i)
  {
    pids[i] = fork ();
    if (pids[i] == -1)
      err_sys ("fork failed");
    if (pids[i] == 0)
    {
      printf ("child %d running\n", getpid ());
      sleep (1);
      exit (0);
    }

    printf ("create child %d\n", pids[i]);
  }

  sleep (1);
  return 0;
}

設置進程數軟硬限制均為 10,啟動 10 個子進程,如果算上本身已達到 11 個,所以肯定會有進程 fork 失敗:

$ ./lmt_nproc
fork failed: Resource temporarily unavailable

但沒想到第一個子進程就創建失敗了,又研究了一下 RLIMIT_NPROC 的含義——"每個實際 UID 用戶擁有的最大進程數"——原來是用戶維度的,並不是子進程維度的,所以還得看目前系統中存在的進程數:

$ ps -aux | grep yunhai01 | wc -l
259

參考這個設置為 265,留 6 個餘量,結果還是一樣。直接調大到 512 ,這回倒是成功了,但是沒法驗證邊界情況了,於是有了下麵探索邊界的代碼:

#include "../apue.h"
#include <sys/resource.h>

int main (int argc, char *argv[])
{
  int ret = 0;
  struct rlimit lmt = { 0 };
  int base = 10;
  int success_cnt = 0;
#define PROC_SIZE 50
  ret = sysconf (_SC_CHILD_MAX);
  printf ("sysconf (_SC_CHILD_MAX) = %d\n", ret);
  while (base < 1024 && base + PROC_SIZE < 1024)
  {
      printf ("============================\n");
      printf ("detect with limit based %d\n", base);
      lmt.rlim_cur = base;
      lmt.rlim_max = 1024;
      ret = setrlimit (RLIMIT_NPROC, &lmt);
      if (ret == -1)
          err_sys ("set rlimit nproc failed");

      ret = sysconf (_SC_CHILD_MAX);
      printf ("sysconf (_SC_CHILD_MAX) = %d\n", ret);
      success_cnt = 0;
      int pids[PROC_SIZE] = { 0 };
      for (int i=0; i<PROC_SIZE; ++ i)
      {
          pids[i] = fork ();
          if (pids[i] == -1)
          {
              if (success_cnt > 0)
                  err_sys ("fork failed");
              else
              {
                  err_msg ("fork failed");
                  break;
              }
          }
          else if (pids[i] == 0)
          {
              printf ("child %d running\n", getpid ());
              sleep (1);
              exit (0);
          }

          printf ("create child %d\n", pids[i]);
          success_cnt ++;
      }

      sleep (1);
      if (base > PROC_SIZE)
          base += PROC_SIZE;
      else
          base *= 2;
  }
  return 0;
}

與之前相比,在外側增加了一個迴圈,用於不停提升探索 RLIMIT_NPROC 的基數,初始時設置為 10,之後以指數方式遞增,直到超過探查子進程數量 (PROC_SIZE),這之後每次增加的數量固定為 PROC_SIZE。

這樣做的上的是在儘快定位邊界的同時,保證一次探查能完全覆蓋失敗的情況,為此也將 PROC_SIZE 從 10 提升到了 50。

設置 RLIMIT_NPROC 時需註意保持硬限制不變 (1024),如果硬限制同軟限制一同降低,後面就再也無法提升軟限制。

最後增加了 sysconf(_SC_CHILD_MAX) 的調用,驗證與 RLMIT_NPROC 的軟限制設置是否同步:

$ ./lmt_nproc
sysconf (_SC_CHILD_MAX) = 4096
============================
detect with limit based 10
sysconf (_SC_CHILD_MAX) = 10
fork failed
============================
detect with limit based 20
sysconf (_SC_CHILD_MAX) = 20
fork failed
============================
detect with limit based 40
sysconf (_SC_CHILD_MAX) = 40
fork failed
============================
detect with limit based 80
sysconf (_SC_CHILD_MAX) = 80
fork failed
============================
detect with limit based 130
sysconf (_SC_CHILD_MAX) = 130
fork failed
============================
detect with limit based 180
sysconf (_SC_CHILD_MAX) = 180
fork failed
============================
detect with limit based 230
sysconf (_SC_CHILD_MAX) = 230
fork failed
============================
detect with limit based 280
sysconf (_SC_CHILD_MAX) = 280
fork failed
============================
detect with limit based 330
sysconf (_SC_CHILD_MAX) = 330
fork failed
============================
detect with limit based 380
sysconf (_SC_CHILD_MAX) = 380
create child 8623
create child 8624
create child 8625
child 8624 running
create child 8626
child 8625 running
create child 8627
child 8627 running
create child 8628
child 8628 running
create child 8629
child 8629 running
create child 8630
child 8630 running
create child 8631
child 8631 running
create chi

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 在開發某一個需求的時候,領導要求使用RocketMQ(阿裡雲版) 作為消息隊列。生產者主要有WebAPI/MVC/JOB(控制台應用程式),然後消費者採用的是Windows服務。那[西瓜程式猿]來記錄一下如何使用RocketMQ(阿裡雲版),給各位小伙伴作為參考防止踩坑。 ...
  • ##### 常用基本配置項 ```xml net35; net40; net45; net451; net452; net46; net461; net462; net47; net471; net472; net48; netstandard2.0; netstandard2.1; netcore ...
  • ### 前言 在非同步編程中,處理非同步操作之間的數據流轉是一個比較常用的操作。`C#`非同步編程提供了一個強大的工具來解決這個問題,那就是`AsyncLocal`。它是一個線程本地存儲的機制,可以在非同步操作之間傳遞數據。它為我們提供了一種簡單而可靠的方式來共用數據,而不必擔心線程切換或非同步上下文的變化。 ...
  • 本文將分享使用 GitHub Actions 完成對一個.Net Core+Vue 的前後端分離項目 zhontai 的構建,並使用 docker 部署到雲伺服器(阿裡雲),及對docker部署.Net Core+Vue的一些經驗分享。 ...
  • [toc] # Linux運維工程師面試題(4) > 祝各位小伙伴們早日找到自己心儀的工作。 > 持續學習才不會被淘汰。 > 地球不爆炸,我們不放假。 > 機會總是留給有有準備的人的。 > 加油,打工人! ## 1 redis 常用的數據類型 - String:字元串,最基礎的數據類型 - List ...
  • # sendto errno -11代碼分析 errno -11在內核代碼中代表EAGAIN(再試⼀次),域套接字sendto過程中` sendto->sock_sendmsg->unix_dgram_sendmsg`,在`unix_dgram_sendmsg`中有兩處會返回 EAGAIN: 第1處 ...
  • 哈嘍大家好,我是鹹魚 今天這篇文章介紹如何在一臺伺服器(以 CentOS 7.9 為例)上通過 `redis-trib.rb` 工具搭建 Redis cluster (三主三從) `redis-trib.rb` 是一個基於 Ruby 編寫的腳本,其功能涵蓋了創建、管理以及維護 Redis 集群的各個 ...
  • 本文首先介紹了進程的控制結構,即進程式控制制塊(PCB),它是表示進程的數據結構,包含了進程的相關信息和資源。PCB之間通過鏈表連接,形成就緒隊列和阻塞隊列,用於進程調度和資源管理。接著,文章詳細探討了進程的切換過程。進程切換是為了保證公平分配CPU時間片,涉及保存和恢復進程的執行上下文、更新進程狀態和... ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...