大家好,我是飛哥!
關于進程和線程,在 Linux 中是一對兒很核心的概念。但是進程和線程到底有啥聯系,又有啥區別,很多人還都沒有搞清楚。
在網上對進程和線程的討論中,很多都是聚集在這二位有啥不同。但事實在 Linux 上,進程和線程的相同點要遠遠大于不同點。在 Linux 下的線程甚至都被稱為了輕量級進程。
我今天就給大家從 Linux 內核實現的角度,給大家深度對比下進程和線程。
一、線程的創建方法
在 Redis 6.0 以上的版本里,也開始支持使用多線程來提供核心服務,我們就以它為例。
在 Redis 主線程啟動以后,會調用 initThreadedIO 來創建多個 io 線程。
redis 源碼地址:https://github.com/redis/redis
創建線程具體調用的是 pthread_create 函數,pthread_create 是在 glibc 庫中實現的。在 glibc 庫中,pthread_create 函數的實現調用路徑是 __pthread_create_2_1 -> create_thread。其中 create_thread 這個函數比較重要,它設置了創建線程時使用的各種 flag 標記。
在上面的代碼中,傳入參數中的各個 flag 標記是非常關鍵的。這里我們先知道一下傳入了 CLONE_VM、CLONE_FS、CLONE_FILES 等標記就行了,后面我們會講內核中針對這些參數做的特殊處理。
接下來的 do_clone 最終會調用一段匯編程序,在匯編里進入 clone 系統調用,之后會進入內核中進行處理。
二、內核中對線程的表示
在開始介紹線程的創建過程之前,先給大家看看內核中表示線程的數據結構。
開篇的時候我說了,進程和線程的相同點要遠遠大于不同點。主要依據就是在 Linux 中,無論進程還是線程,都是抽象成了 task 任務,在源碼里都是用 task_struct 結構來實現的。
我們來看 task_struct 具體的定義,它位于 include/linux/sched.h
這個數據結構已經在上一篇文章《Linux進程是如何創建出來的?》中,我們詳細介紹過了。
對于線程來講,所有的字段都是和進程一樣的(本來就是一個結構體來表示的)。包括狀態、pid、task 樹關系、地址空間、文件系統信息、打開的文件信息等等字段,線程也都有。
這也就是我前面說的,進程和線程的相同點要遠遠大于不同點,本質上是同一個東西,都是一個 task_struct !正因為進程線程如此之相像,所以在 Linux 下的線程還有另外一個名字,叫輕量級進程。至于說輕量在哪兒,稍后我們再說。
這里我們稍微說一下 pid 和 tgid 這兩個字段。在 Linux 中,每一個 task_struct 都需要被唯一的標識,它的 pid 就是唯一標識號。
對于進程來說,這個 pid 就是我們平時常說的進程 pid。
對于線程來說,我們假如一個進程下創建了多個線程出來。那么每個線程的 pid 都是不同的。但是我們一般又需要記錄線程是屬于哪個進程的。這時候,tgid 就派上用場了,通過 tgid 字段來表示自己所歸屬的進程 ID。
這樣內核通過 tgid 可以知道線程屬于哪個進程。
三、線程創建過程
要想知道進程和線程的區別到底在哪兒,我們從線程的創建過程來詳細看一下。
3.1 回顧進程創建
在《Linux進程是如何創建出來的?》一文中我們了解了進程的創建過程。事實上,進程線程創建的時候,使用的函數看起來不一樣。但實際在底層實現上,最終都是使用同一個函數來實現的。
我們再簡單回顧一下創建進程時 fork 系統調用的源碼,fork 調用主要就是執行了 do_fork 函數。注意:fork 函數調用 do_fork 的傳的參數分別是SIGCHLD、0,0,NULL,NULL。
do_fork 函數又調用 copy_process 完成進程的創建。
3.2 線程的創建
我們在本文第一小節里介紹到 lib 庫函數 pthread_create 會調用到 clone 系統調用,為其傳入了一組 flag。
好,我們找到 clone 系統調用的實現。
同樣,do_fork 函數還是會執行到 copy_process 來完成實際的創建。
3.3 進程線程創建異同
可見和創建進程時使用的 fork 系統調用相比,創建線程的 clone 系統調用幾乎和 fork 差不多,也一樣使用的是內核里的 do_fork 函數,最后走到 copy_process 來完整創建。
不過創建過程的區別是二者在調用 do_fork 時傳入的 clone_flags 里的標記不一樣!。
創建進程時的 flag:僅有一個 SIGCHLD
創建線程時的 flag:包括 CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGNAL、CLONE_SETTLS、CLONE_PARENT_SETTID、CLONE_CHILD_CLEARTID、CLONE_SYSVSEM。
關于這些 flag 的含義,我們選幾個關鍵的做一個簡單的介紹,后面介紹 do_fork 細節的時候會再次涉及到。
CLONE_VM: 新 task 和父進程共享地址空間
CLONE_FS:新 task 和父進程共享文件系統信息
CLONE_FILES:新 task 和父進程共享文件描述符表
這些 flag 會對 task_struct 產生啥影響,我們接著看接下來的內容。
四、揭秘 do_fork 系統調用
在本節中我們以動態的視角來看一下線程的創建過程.
前面我們看到,進程和線程創建都是調用內核中的 do_fork 函數來執行的。在 do_fork 的實現中,核心是一個 copy_process 函數,它以拷貝父進程(線程)的方式來生成一個新的 task_struct 出來。
在創建完畢后,調用 wake_up_new_task 將新創建的任務添加到就緒隊列中,等待調度器調度執行。這個代碼很長,我對其進行了一定程度的精簡。
可見,copy_process 先是復制了一個新的 task_struct 出來,然后調用 copy_xxx 系列的函數對 task_struct 中的各種核心對象進行拷貝處理,還申請了 pid 。接下來我們分小節來查看該函數的每一個細節。
4.1 復制 task_struct 結構體
注意一下,上面調用 dup_task_struct 時傳入的參數是 current,它表示的是當前任務。在 dup_task_struct 里,會申請一個新的 task_struct 內核對象,然后將當前任務復制給它。需要注意的是,這次拷貝只會拷貝 task_struct 結構體本身,它內部包含的 mm_struct 等成員不會被復制。
我們來簡單看下具體的代碼。
其中 alloc_task_struct_node 用于在 slab 內核內存管理區中申請一塊內存出來。關于 slab 機制請參考- 內核內存管理
申請完內存后,調用 arch_dup_task_struct 進行內存拷貝。
4.2 拷貝打開文件列表
我們先回憶一下前面的內容,創建線程調用 clone 系統調用的時候,傳入了一堆的 flag,其中有一個就是 CLONE_FILES。如果傳入了 CLONE_FILES 標記,就會復用當前進程的打開文件列表 - files 成員。
好了,我們繼續看 copy_files 具體實現。
從代碼看出,如果指定了 CLONE_FILES(創建線程的時候),只是在原有的 files_struct 里面 +1 就算是完事了,指針不變,仍然是復用創建它的進程的 files_struct 對象。
這就是進程和線程的其中一個區別,對于進程來講,每一個進程都需要獨立的 files_struct。但是對于線程來講,它是和創建它的線程復用 files_struct 的。
4.3 拷貝文件目錄信息
再回憶一下創建線程的時候,傳入的 flag 里也包括 CLONE_FS。如果指定了這個標志,就會復用當前進程的文件目錄 - fs 成員。
對于創建進程來講,沒有傳入這個標志,就會新創建一個 fs 出來。
?
好,我們繼續看 copy_fs 的實現。
和 copy_files 函數類似,在 copy_fs 中如果指定了 CLONE_FS(創建線程的時候),并沒有真正申請獨立的 fs_struct 出來,近幾年只是在原有的 fs 里的 users +1 就算是完事。
而在創建進程的時候,由于沒有傳遞這個標志,會進入到 copy_fs_struct 函數中申請新的 fs_struct 并進行賦值拷貝。
4.4 拷貝內存地址空間
創建線程的時候帶了 CLONE_VM 標志,而創建進程的時候沒帶。接下來在 copy_mm 函數 中會根據是否有這個標志來決定是該和當前線程共享一份地址空間 mm_struct,還是創建一份新的。
對于線程來講,由于傳入了 CLONE_VM 標記,所以不會申請新的 mm_struct 出來,而是共享其父進程的。
多線程程序中的所有線程都會共享其父進程的地址空間。
?
而對于多進程程序來說,每一個進程都有獨立的 mm_struct(地址空間)。
?
因為在內核中線程和進程都是用 task_struct 來表示,只不過線程和進程的區別是會和創建它的父進程共享打開文件列表、目錄信息、虛擬地址空間等數據結構,會更輕量一些。所以在 Linux 下的線程也叫輕量級進程。
在打開文件列表、目錄信息、內存虛擬地址空間中,內存虛擬地址空間是最重要的。因此區分一個 Task 任務該叫線程還是該叫進程,一般習慣上就看它是否有獨立的地址空間。如果有,就叫做進程,沒有,就叫做線程。
這里展開多說一句,對于內核任務來說,無論有多少個任務,其使用地址空間都是同一個。所以一般都叫內核線程,而不是內核進程。
五 結論
創建線程的整個過程我們就介紹完了。回頭總結一下,對于線程來講,其地址空間 mm_struct、目錄信息 fs_struct、打開文件列表 files_struct 都是和創建它的任務共享的。
但是對于進程來講,地址空間 mm_struct、掛載點 fs_struct、打開文件列表 files_struct 都要是獨立擁有的,都需要去申請內存并初始化它們。
?
總之,在 Linux 內核中并沒有對線程做特殊處理,還是由 task_struct 來管理。從內核的角度看,用戶態的線程本質上還是一個進程。只不過和普通進程比,稍微“輕量”了那么一些。
那么線程具體能輕量多少呢?我之前曾經做過一個進程和線程的上下文切換開銷測試。進程的測試結果是一次上下文切換平均 2.7 - 5.48 us 之間。線程上下文切換是 3.8 us左右。總的來說,進程線程切換還是沒差太多。
評論
查看更多