《TCP/IP網絡編程》學習筆記 | Chapter 18:多線程服務器端的實現
- 《TCP/IP網絡編程》學習筆記 | Chapter 18:多線程服務器端的實現
- 線程的概念
- 引入線程的背景
- 線程與進程的區別
- 線程創建與運行
- pthread_create
- pthread_join
- 可在臨界區內調用的函數
- 工作(Worker)線程模型
- 線程存在的問題和臨界區
- 多個線程訪問同一變量的問題
- 臨界區位置
- 線程同步
- 同步的兩面性
- 互斥量
- 信號量
- 銷毀線程的 3 種方法
- 多線程并發服務器端的實現
- 習題
- (1)單 CPU 系統中如何同時執行多個進程?請解釋該過程中發生的上下文切換。
- (2)為何線程的上下文切換速度相對更快?線程間數據交換為何不需要類似 IPC 的特別技術?
- (3)請從執行流角度說明進程和線程的區別。
- (4)下列關于臨界區的說法錯誤的是?
- (5)下列關于線程同步的描述錯誤的是?
- (6)請說明完全銷毀 Linux 線程的 2 種辦法。
- (7)請利用多線程技術實現回聲服務器端,但要讓所有線程共享保存客戶端消息的內存空間(char數組)。這么做只是為了應用本章的同步技術,其實不符合常理。
- (8)上一題要求所有線程共享保存回聲消息的內存空間,如果采用這種方式,無論是否同步都會產生問題。請說明每種情況各產生哪些問題。
《TCP/IP網絡編程》學習筆記 | Chapter 18:多線程服務器端的實現
線程的概念
引入線程的背景
多進程模型的缺點:
- 創建(復制)進程的工作本身會給操作系統帶來相當沉重的負擔
- 進程間通信的實現難度高,為了完成進程間數據交換,需要特殊的 IPC 技術
- 每秒少則數十次、多則數千次的“上下文切換”(Context Switching)是創建進程時最大的開銷
只有一個 CPU 的系統是將時間分成多個微小的塊后分配給了多個進程。為了分時使用 CPU ,需要「上下文切換」的過程。
運行程序前需要將相應進程信息讀入內存,如果運行進程 A 后緊接著需要運行進程 B ,就應該將進程 A 相關信息移出內存,并讀入進程 B 相關信息。這就是上下文切換。但是此時進程 A 的數據將被移動到硬盤,所以上下文切換要很長時間,即使通過優化加快速度,也會存在一定的局限。
為了保持多進程的優點,同時在一定程度上克服其缺點,人們引入了線程(Thread)的概念。這是為了將進程的各種劣勢降至最低程度(不是直接消除)而設立的一種「輕量級進程」。線程比進程具有如下優點:
- 線程的創建和上下文切換比進程的創建和上下文切換更快
- 線程間交換數據無需特殊技術
線程與進程的區別
每個進程的內存空間由三個部分構成:
- 數據區:用于保存全局變量
- 堆區域:用于向malloc等函數的動態分配提供空間
- 棧區域:函數運行時會使用
每個進程都有獨立的這種空間,多個進程的內存結構如圖所示:
為了得到多條代碼流而復制整個內存區域的負擔太重了,所以有了線程的出現。不應完全分離內存結構,而只需要分離棧區域。通過這種方式可以獲得如下優勢:
- 上下文切換時不需要切換數據區和堆
- 可以利用數據區和堆交換數據
實際上這就是線程。線程為了保持多條代碼執行流而隔開了棧區域,因此具有如下圖所示的內存結構:
多個線程共享數據區和堆。為了保持這種結構,線程將在進程內創建并運行。也就是說,進程和線程可以定義為如下形式:
- 進程:在操作系統中構成單獨執行流的單位
- 線程:在進程內構成單獨執行流的單位
如果說進程在操作系統內部生成多個執行流,那么線程就在同一進程內部創建多條執行流。因此,操作系統、進程、線程之間的關系可以表示為下圖:
線程創建與運行
pthread_create
線程具有單獨的執行流,因此需要單獨定義線程的 main 函數,還需要請求操作系統在單獨的執行流中執行該函數,完成該功能的函數如下:
#include <pthread.h>int pthread_create(pthread_t *restrict thread,const pthread_attr_t *restrict attr,void *(*start_routine)(void *),void *restrict arg);
參數:
- thread:保存新創建線程 ID 的變量地址值。線程與進程相同,也需要用于區分不同線程的 ID
- attr:用于傳遞線程屬性的參數,傳遞 NULL 時,創建默認屬性的線程
- start_routine:相當于線程 main 函數的、在單獨執行流中執行的函數地址值(函數指針)
- arg:通過第三個參數傳遞的調用函數時包含傳遞參數信息的變量地址值
成功時返回 0,失敗時返回 -1。
要想理解好上述函數的參數,需要熟練掌握restrict關鍵字和函數指針相關語法。但如果只關注使用方法,那么該函數的使用比想象中要簡單。下面通過簡單示例了解該函數的功能。
#include <stdio.h>
// #include <stdlib.h>
#include <pthread.h>
#include <unistd.h>void *thread_main(void *arg);int main(int argc, char *argv[])
{pthread_t t_id;int thread_param = 5;// 請求創建一個線程,從 thread_main 調用開始,在單獨的執行流中運行。同時傳遞參數if (pthread_create(&t_id, NULL, thread_main, (void *)&thread_param) != 0){puts("pthread_create() error");return -1;}sleep(10); // 延遲進程終止時間puts("end of main");// system("pause");return 0;
}
void *thread_main(void *arg) // 傳入的參數是 pthread_create 的第四個
{int i;int cnt = *((int *)arg);for (int i = 0; i < cnt; i++){sleep(1);puts("running thread");}return NULL;
}
線程相關代碼在編譯時需要添加-lpthread選項聲明需要連接線程庫,只有這樣才可以調用頭文件pthread.h中聲明的函數。
運行結果:
上述程序的執行如下圖所示。其中,虛線代表執行流稱,向下的箭頭指的是執行流,橫向箭頭是函數調用。
可以看出,程序在主進程沒有結束時,生成的線程每隔一秒輸出一次“running thread”,共輸出5次,最后輸出 main 函數中的“end of main”。
但是如果主進程沒有等待十秒,而是直接結束,將主函數中的 sleep(10) 改成 sleep(2)。
這樣不會輸出 5 次“running thread”,main 函數返回后整個進程將被銷毀,無論線程有沒有運行完畢,如下圖所示。
pthread_join
通過調用 sleep 函數控制線程的執行相當于預測程序的執行流程,但實際上這是不可能完成的事情。而且稍有不慎,很可能干擾程序的正常執行流。例如,怎么可能在上述示例中準確預測 thread_main 函數的運行時間,并讓 main 函數恰好等待這么長時間呢?因此,我們不用 sleep 函數,而是通常利用下面的函數控制線程的執行流。
#include <pthread.h>int pthread_join(pthread_t thread, void **status);
參數:
- thread:該參數值 ID 的線程終止后才會從該函數返回
- status:保存線程的 main 函數返回值的指針的變量地址值
成功時返回 0,失敗時返回 -1。
調用該函數的進程(或線程)將進入等待狀態,直到第一個參數為ID的線程終止為止。另外,還能得到線程的 main 函數返回值。
下面是該函數的用法示例代碼:
#include <stdio.h>
// #include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>void *thread_main(void *arg);int main(int argc, char *argv[])
{pthread_t t_id;int thread_param = 5;void *thr_ret;// 請求創建一個線程,從 thread_main 調用開始,在單獨的執行流中運行,同時傳遞參數if (pthread_create(&t_id, NULL, thread_main, (void *)&thread_param) != 0){puts("pthread_create() error");return -1;}// main函數將等待 ID 保存在 t_id 變量中的線程終止if (pthread_join(t_id, &thr_ret) != 0){puts("pthread_join() error");return -1;}printf("Thread return message : %s \n", (char *)thr_ret);free(thr_ret);// system("pause");return 0;
}void *thread_main(void *arg) // 傳入的參數是 pthread_create 的第四個
{int i;int cnt = *((int *)arg);char *msg = (char *)malloc(sizeof(char) * 50);strcpy(msg, "Hello, I'm thread~ \n");for (int i = 0; i < cnt; i++){sleep(1);puts("running thread");}return (void *)msg; // 返回值是 thread_main 函數中內部動態分配的內存空間地址值
}
運行結果:
可以看出,線程輸出了5次字符串,并且把返回值給了主進程。
下面是該函數的執行流程圖:
可在臨界區內調用的函數
在同步的程序設計中,臨界區指的是一個訪問共享資源(如共享設備、共享存儲器)的程序片段,而這些共享資源有無法同時被多個線程訪問的特性。
稍后將討論哪些代碼可能成為臨界區,多個線程同時執行臨界區代碼時會產生哪些問題等內容。現階段只需理解臨界區的概念即可。根據臨界區是否引起問題,函數可分為以下2類:
- 線程安全函數
- 非線程安全函數
線程安全函數被多個線程同時調用也不會發生問題。反之,非線程安全函數被同時調用時會引發問題。但這并非有關于臨界區的討論,線程安全的函數中同樣可能存在臨界區。只是在線程安全的函數中,同時被多個線程調用時可通過一些措施避免問題。
幸運的是,大多數標準函數都是線程安全函數。操作系統在定義非線程安全函數的同時,提供了具有相同功能的線程安全的函數。
比如,第 8 章的 gethostbyname 函數不是線程安全的:
struct hostent *gethostbyname(const char *hostname);
同時提供線程安全的同一功能的函數:
struct hostent *gethostbyname_r(const char *name,struct hostent *result,char *buffer,int intbuflen,int *h_errnop);
線程安全函數結尾通常是 _r(與 Windows 不同)。但是使用線程安全函數會給程序員帶來額外的負擔,可以通過以下方法自動將 gethostbyname 函數調用改為 gethostbyname_r 函數調用:聲明頭文件前定義 _REENTRANT 宏。
無需特意更改源代碼,可以在編譯的時候指定編譯參數定義宏:
gcc -D_REENTRANT mythread.c -o mthread -lpthread
工作(Worker)線程模型
下面的示例是計算從 1 到 10 的和,但并不是通過 main 函數進行運算,而是創建兩個線程,其中一個線程計算 1 到 5 的和,另一個線程計算 6 到 10 的和,main 函數只負責輸出運算結果。這種方式的線程模型稱為「工作線程」。
#include <stdio.h>
// #include <stdlib.h>
#include <pthread.h>void *thread_summation(void *arg);
int sum = 0;int main(int argc, char *argv[])
{pthread_t id_t1, id_t2;int range1[] = {1, 5};int range2[] = {6, 10};pthread_create(&id_t1, NULL, thread_summation, (void *)range1);pthread_create(&id_t2, NULL, thread_summation, (void *)range2);pthread_join(id_t1, NULL);pthread_join(id_t2, NULL);printf("result: %d \n", sum);// system("pause");return 0;
}void *thread_summation(void *arg)
{int start = ((int *)arg)[0];int end = ((int *)arg)[1];while (start <= end){sum += start;start++;}return NULL;
}
該程序的執行流程圖:
運行結果:
可以看出計算結果正確,兩個線程都用了全局變量 sum,證明了 2 個線程共享保存全局變量的數據區。
但是本例子本身存在臨界區相關問題,可以從下面的代碼看出,下面的代碼和上面的代碼相似,只是增加了發生臨界區錯誤的可能性,即使在高配置系統環境下也容易產生的錯誤。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>#define NUM_THREAD 100void *thread_inc(void *arg);
void *thread_des(void *arg);
long long num = 0;int main(int argc, char *argv[])
{pthread_t thread_id[NUM_THREAD];int i;printf("sizeof long long: %d \n", sizeof(long long));for (i = 0; i < NUM_THREAD; i++){if (i % 2)pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);elsepthread_create(&(thread_id[i]), NULL, thread_des, NULL);}for (i = 0; i < NUM_THREAD; i++)pthread_join(thread_id[i], NULL);printf("result: %lld \n", num);system("pause");return 0;
}void *thread_inc(void *arg)
{int i;for (i = 0; i < 50000000; i++)num += 1;return NULL;
}
void *thread_des(void *arg)
{int i;for (i = 0; i < 50000000; i++)num -= 1;return NULL;
}
運行結果:
理論上來說,上面代碼的最后結果應該是 0。但實際上,每次運行的結果都不同,且不是 0。
線程存在的問題和臨界區
多個線程訪問同一變量的問題
上述代碼的問題:2 個線程正在同時訪問全局變量 num。
在詳細解釋問題之前,先了解一個概念———寄存器,它是存放值的地方。當線程從進程中獲取值進行運算的時候,線程實際上會將這個值放入自己的寄存器在計算完成后才會重新更新進程內部的數據區。這種機制看似沒啥問題,但是如果一但一個線程在將寄存器中的值更新回數據區之前它被操作系統中斷掉,轉而運行第二個線程,那么此時第二個線程實際上使用的是一個過期的值。
任何內存空間,只要被同時訪問,都有可能發生問題。 因此,線程訪問變量 num 時應該阻止其他線程訪問,直到線程 1 運算完成。這就是同步。
臨界區位置
臨界區的定義:函數內同時運行多個線程時引發問題的多條語句構成的代碼塊。
全局變量 num 不能視為臨界區,因為它不是引起問題的語句,只是一個內存區域的聲明。
臨界區通常位于由線程運行的函數內部。
下面是剛才代碼的兩個 main 函數:
void *thread_inc(void *arg)
{int i;for (i = 0; i < 50000000; i++)num += 1; // 臨界區return NULL;
}
void *thread_des(void *arg)
{int i;for (i = 0; i < 50000000; i++)num -= 1; // 臨界區return NULL;
}
由上述代碼可知,臨界區并非 num 本身,而是訪問 num 的兩條語句,這兩條語句可能由多個線程同時運行,也是引起這個問題的直接原因。產生問題的原因可以分為以下三種情況:
- 2 個線程同時執行 thread_inc 函數
- 2 個線程同時執行 thread_des 函數
- 2 個線程分別執行 thread_inc 和 thread_des 函數
比如發生以下情況:線程 1 執行 thread_inc 的 num+=1 語句的同時,線程 2 執行 thread_des 函數的 num-=1 語句。
也就是說,兩條不同的語句由不同的線程執行時,也有可能構成臨界區。前提是這 2 條語句訪問同一內存空間。
線程同步
前面討論了線程中存在的問題,下面就是解決方法——線程同步。
同步的兩面性
線程同步用于解決線程訪問順序引發的問題。需要同步的情況可以從如下兩方面考慮:
- 同時訪問同一內存空間時發生的情況
- 需要指定訪問同一內存空間的線程順序的情況
之前已解釋過前一種情況,因此重點討論第二種情況。這是「控制線程執行的順序」的相關內容。假設有 A、B 兩個線程,線程 A 負責向指定的內存空間內寫入數據,線程 B 負責取走該數據。所以這是有順序的,不按照順序就可能發生問題。所以這種也需要進行同步。
互斥量
互斥量是 Mutual Exclusion 的簡寫,表示不允許多個線程同時訪問。互斥量主要用于解決線程同步訪問的問題。
互斥量就像是一把鎖,把臨界區鎖起來,不允許多個線程同時訪問。下面是互斥量的創建及銷毀函數:
#include <pthread.h>int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr);int pthread_mutex_destroy(pthread_mutex_t *mutex);
參數:
- mutex:創建互斥量時傳遞保存互斥量的變量地址值,銷毀時傳遞需要銷毀的互斥量地址
- attr:傳遞即將創建的互斥量屬性,沒有特別需要指定的屬性時傳遞 NULL
成功時返回 0,失敗時返回其他值。
從上述函數聲明中可以看出,為了創建相當于鎖系統的互斥量,需要聲明如下 pthread_mutex_t 型變量:
pthread_mutex_t mutex;
該變量的地址值傳遞給 pthread_mutex_init 函數,用來保存操作系統創建的互斥量(鎖系統)。調用 pthread_mutex_destroy 函數時同樣需要該信息。如果不需要配置特殊的互斥量屬性,則向第二個參數傳遞 NULL 時,可以利用 PTHREAD_MUTEX_INITIALIZER 宏進行如下聲明:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
但推薦盡可能的使用 pthread_mutex_init 函數進行初始化,因為通過宏進行初始化時很難發現發生的錯誤。
#include <pthread.h>int pthread_mutex_lock(pthread_mutex_t *mutex);int pthread_mutex_unlock(pthread_mutex_t *mutex);
成功時返回 0 ,失敗時返回其他值。
進入臨界區前調用的函數就是 pthread_mutex_lock 。調用該函數時,發現有其他線程已經進入臨界區,pthread_mutex_lock 函數不會返回,直到里面的線程調用 pthread_mutex_unlock 函數退出臨界區位置。也就是說,其他線程讓出臨界區之前,當前線程一直處于阻塞狀態。
接下來整理一下代碼的編寫方式。創建好互斥量的前提下,可以通過如下結構保護臨界區:
pthread_mutex_lock(&mutex);
// 臨界區開始
// ...
// 臨界區結束
pthread_mutex_unlock(&mutex);
簡言之,就是利用 lock 和 unlock 函數圍住臨界區的兩端。此時互斥量相當于一把鎖,阻止多個線程同時訪問,還有一點要注意,線程退出臨界區時,如果忘了調用 pthread_mutex_unlock 函數,那么其他為了進入臨界區而調用 pthread_mutex_lock 的函數無法擺脫阻塞狀態。這種情況稱為「死鎖」,需要格外注意。
下面利用互斥量解決之前示例中遇到的問題:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>#define NUM_THREAD 100void *thread_inc(void *arg);
void *thread_des(void *arg);long long num = 0;
pthread_mutex_t mutex; // 保存互斥量讀取值的變量int main(int argc, char *argv[])
{pthread_t thread_id[NUM_THREAD];int i;pthread_mutex_init(&mutex, NULL); // 創建互斥量for (i = 0; i < NUM_THREAD; i++){if (i % 2)pthread_create(&(thread_id[i]), NULL, thread_inc, NULL);elsepthread_create(&(thread_id[i]), NULL, thread_des, NULL);}for (i = 0; i < NUM_THREAD; i++)pthread_join(thread_id[i], NULL);printf("result: %lld \n", num);pthread_mutex_destroy(&mutex); // 銷毀互斥量system("pause");return 0;
}void *thread_inc(void *arg)
{int i;pthread_mutex_lock(&mutex); // 上鎖for (i = 0; i < 50000000; i++)num += 1; // 臨界區pthread_mutex_unlock(&mutex); // 解鎖return NULL;
}
void *thread_des(void *arg)
{int i;for (i = 0; i < 50000000; i++){pthread_mutex_lock(&mutex); // 上鎖num -= 1; // 臨界區pthread_mutex_unlock(&mutex); // 解鎖}return NULL;
}
運行結果:
thread_inc 函數中臨界區的劃分范圍比較大,這是考慮到如下優點所做的決定:最大限度減少互斥量lock、unlock函數的調用次數。
信號量
下面介紹信號量。信號量與互斥量極為相似,在互斥量的基礎上很容易理解信號量。
信號量(Semaphore),是一個同步對象,用于保持在 0 至指定最大值之間的一個計數值。當線程完成一次對該semaphore對象的等待(wait)時,該計數值減一;當線程完成一次對semaphore對象的釋放(release)時,計數值加一。
如果信號量是一個任意的整數,通常被稱為計數信號量(Counting semaphore),或一般信號量(general semaphore)。如果信號量只有二進制的 0 或 1,稱為二進制信號量(binary semaphore)。在linux系統中,二進制信號量(binary semaphore)又稱互斥鎖(Mutex)。
此處只涉及利用「二進制信號量」(只用 0 和 1)完成「控制線程順序」為中心的同步方法。
下面是信號量的創建及銷毀方法:
#include <semaphore.h>int sem_init(sem_t *sem, int pshared, unsigned int value);int sem_destroy(sem_t *sem);
參數:
- sem:創建信號量時保存信號量的變量地址值,銷毀時傳遞需要銷毀的信號量變量地址值
- pshared:傳遞其他值時,創建可由多個繼承共享的信號量;傳遞 0 時,創建只允許 1 個進程內部使用的信號量。需要完成同一進程的線程同步,故為 0
- value:指定創建信號量的初始值
成功時返回 0 ,失敗時返回其他值。
上述的 shared 參數超出了我們的關注范圍,故默認向其傳遞為 0。
下面是信號量中相當于互斥量 lock、unlock 的函數:
#include <semaphore.h>int sem_post(sem_t *sem);int sem_wait(sem_t *sem);
參數:
- sem:傳遞保存信號量讀取值的變量地址值,傳遞給 sem_post 的信號量增1,傳遞給 sem_wait 時信號量減 1
成功時返回 0 ,失敗時返回其他值。
調用 sem_init 函數時,操作系統將創建信號量對象,此對象中記錄這「信號量值」(Semaphore Value)整數。該值在調用 sem_post 函數時增加 1 ,調用 sem_wait 函數時減 1。但信號量的值不能小于 0 ,因此,在信號量為 0 的情況下調用 sem_wait 函數時,調用的線程將進入阻塞狀態(因為函數未返回)。當然,此時如果有其他線程調用 sem_post 函數,信號量的值將變為 1 ,而原本阻塞的線程可以將該信號重新減為 0 并跳出阻塞狀態。
實際上就是通過這種特性完成臨界區的同步操作,假設信號量的初始值為 1,可以通過如下形式同步臨界區:
sem_wait(&sem); // 信號量變為 0...
// 臨界區的開始
// ...
// 臨界區的結束
sem_post(&sem); // 信號量變為 1...
上述代碼結構中,調用 sem_wait 函數進入臨界區的線程在調用 sem_post 函數前不允許其他線程進入臨界區。信號量的值在 0 和 1 之間跳轉,因此,具有這種特性的機制稱為「二進制信號量」。
下面給出關于控制訪問順序的同步的例子,場景為:線程 A 從用戶輸入得到值后存入全局變量 num ,此時線程 B 將取走該值并累加。該過程一共進行 5 次,完成后輸出總和并退出程序。
#include <stdio.h>
// #include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>void *read(void *arg);
void *accu(void *arg);static sem_t sem_one;
static sem_t sem_two;
static int num;int main(int argc, char const *argv[])
{pthread_t id_t1, id_t2;sem_init(&sem_one, 0, 0);sem_init(&sem_two, 0, 1);pthread_create(&id_t1, NULL, read, NULL);pthread_create(&id_t2, NULL, accu, NULL);pthread_join(id_t1, NULL);pthread_join(id_t2, NULL);sem_destroy(&sem_one);sem_destroy(&sem_two);// system("pause");return 0;
}void *read(void *arg)
{int i;for (i = 0; i < 5; i++){fputs("Input num: ", stdout);sem_wait(&sem_two);scanf("%d", &num);sem_post(&sem_one);}return NULL;
}void *accu(void *arg)
{int sum = 0, i;for (i = 0; i < 5; i++){sem_wait(&sem_one);sum += num;sem_post(&sem_two);}printf("Result: %d \n", sum);return NULL;
}
運行結果:
從上述代碼可以看出,設置了兩個信號量,one 的初始值為 0 ,two 的初始值為 1。在調用函數的時候,「讀」的前提是 two 可以減一,如果不能減一就會阻塞在這里,一直等到「計算」操作完畢后,給 two 加一,然后就可以繼續執行下一句輸入。對于「計算」函數,也類似。
銷毀線程的 3 種方法
Linux 線程并不是在首次調用的線程main函數返回時自動銷毀,所以用如下 2 種方法之一加以明確。否則由線程創建的內存空間將一直存在。
- 調用 pthread_join 函數
- 調用 pthread_detach 函數
之前調用過 pthread_join 函數。調用該函數時,不僅會等待線程終止,還會引導線程銷毀。但該函數的問題是,線程終止前,調用該函數的線程將進入阻塞狀態。因此,通過如下函數調用引導線程銷毀:
#include <pthread.h>int pthread_detach(pthread_t th);
參數:
- thread:終止的同時需要銷毀的線程 ID
成功時返回 0 ,失敗時返回其他值。
調用上述函數不會引起線程終止或進入阻塞狀態,可以通過該函數引導銷毀線程創建的內存空間。調用該函數后不能再針對相應線程調用 pthread_join 函數。
多線程并發服務器端的實現
服務器端:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <pthread.h>#define BUF_SIZE 100
#define MAX_CLNT 256void *handle_clnt(void *arg);
void send_msg(char *msg, int len);
void error_handling(char *msg);int clnt_cnt = 0; // 記錄連接的客戶端數量
int clnt_socks[MAX_CLNT]; // 存儲連接的客戶端套接字
pthread_mutex_t mutex; // 互斥鎖,保護共享資源int main(int argc, char *argv[])
{int serv_sock, clnt_sock;struct sockaddr_in serv_adr, clnt_adr;int clnt_adr_sz;pthread_t t_id;if (argc != 2){printf("Usage : %s <port>\n", argv[0]);exit(1);}pthread_mutex_init(&mutex, NULL); // 初始化互斥鎖// 進行服務器套接字的創建和綁定serv_sock = socket(PF_INET, SOCK_STREAM, 0);memset(&serv_adr, 0, sizeof(serv_adr));serv_adr.sin_family = AF_INET;serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);serv_adr.sin_port = htons(atoi(argv[1]));if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)error_handling("bind() error");if (listen(serv_sock, 5) == -1)error_handling("listen() error");while (1){// 接受客戶端連接clnt_adr_sz = sizeof(clnt_adr);clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);pthread_mutex_lock(&mutex); // 上鎖,保護共享資源clnt_socks[clnt_cnt++] = clnt_sock; // 將新連接的客戶端套接字存儲起來pthread_mutex_unlock(&mutex); // 解鎖// 創建線程為新客戶端服務,并且把 clnt_sock 作為參數傳遞pthread_create(&t_id, NULL, handle_clnt, (void *)&clnt_sock);// 引導線程銷毀,不阻塞pthread_detach(t_id);// 打印客戶端的 IP 地址printf("Connected client IP: %s \n", inet_ntoa(clnt_adr.sin_addr));}close(serv_sock);return 0;
}void *handle_clnt(void *arg)
{int clnt_sock = *((int *)arg);int str_len = 0;char msg[BUF_SIZE];// 讀取客戶端發來的消息,并廣播給所有客戶端while ((str_len = read(clnt_sock, msg, sizeof(msg))) != 0)send_msg(msg, str_len);// 當收到消息長度為 0 時,表示客戶端斷開連接pthread_mutex_lock(&mutex); // 上鎖for (int i = 0; i < clnt_cnt; i++) // 從連接列表中移除斷開的客戶端{if (clnt_socks[i] == clnt_sock){while (i++ < clnt_cnt - 1)clnt_socks[i] = clnt_socks[i + 1];break;}}clnt_cnt--;pthread_mutex_unlock(&mutex); // 解鎖close(clnt_sock);return NULL;
}// 將消息發送給所有連接的客戶端
void send_msg(char *msg, int len)
{pthread_mutex_lock(&mutex); // 上鎖// 向每個連接的客戶端發送消息for (int i = 0; i < clnt_cnt; i++)write(clnt_socks[i], msg, len);pthread_mutex_unlock(&mutex); // 解鎖
}void error_handling(char *msg)
{fputs(msg, stderr);fputc('\n', stderr);exit(1);
}
客戶端:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <pthread.h>#define BUF_SIZE 100
#define NAME_SIZE 20void *send_msg(void *arg);
void *recv_msg(void *arg);
void error_handling(char *msg);char name[NAME_SIZE] = "[DEFAULT]"; // 客戶端名稱
char msg[BUF_SIZE]; // 存儲要發送的消息int main(int argc, char *argv[])
{int sock;struct sockaddr_in serv_addr;pthread_t snd_thread, rcv_thread;void *thread_return;// 解析命令行參數,設置客戶端名稱、服務器 IP 和端口if (argc != 4){printf("Usage : %s <IP> <port> <name>\n", argv[0]);exit(1);}sprintf(name, "[%s]", argv[3]);// 創建客戶端套接字,設置服務器地址結構體sock = socket(PF_INET, SOCK_STREAM, 0);memset(&serv_addr, 0, sizeof(serv_addr));serv_addr.sin_family = AF_INET;serv_addr.sin_addr.s_addr = inet_addr(argv[1]);serv_addr.sin_port = htons(atoi(argv[2]));if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)error_handling("connect() error");// 創建發送和接收消息的線程pthread_create(&snd_thread, NULL, send_msg, (void *)&sock);pthread_create(&rcv_thread, NULL, recv_msg, (void *)&sock);// 等待線程結束pthread_join(snd_thread, &thread_return);pthread_join(rcv_thread, &thread_return);close(sock);return 0;
}// 發送消息的線程函數
void *send_msg(void *arg)
{int sock = *((int *)arg);char name_msg[NAME_SIZE + BUF_SIZE];while (1){fgets(msg, BUF_SIZE, stdin);if (!strcmp(msg, "q\n") || !strcmp(msg, "Q\n")) // 判斷是否輸入退出指令{close(sock);exit(0);}sprintf(name_msg, "%s %s", name, msg);write(sock, name_msg, strlen(name_msg)); // 發送帶有客戶端名稱的消息}return NULL;
}// 接收消息的線程函數
void *recv_msg(void *arg)
{int sock = *((int *)arg);char name_msg[NAME_SIZE + BUF_SIZE];int str_len;while (1){str_len = read(sock, name_msg, NAME_SIZE + BUF_SIZE - 1);if (str_len == -1)return (void *)-1;name_msg[str_len] = 0;// 打印接收到的消息fputs(name_msg, stdout);}return NULL;
}void error_handling(char *msg)
{fputs(msg, stderr);fputc('\n', stderr);exit(1);
}
上面的服務端示例中,需要掌握臨界區的構成,訪問全局變量 clnt_cnt 和數組 clnt_socks 的代碼將構成臨界區,添加和刪除客戶端時,變量 clnt_cnt 和數組 clnt_socks 將同時發生變化。
客戶端示例為了分離輸入和輸出過程而創建了 2 個線程。
運行結果:
習題
(1)單 CPU 系統中如何同時執行多個進程?請解釋該過程中發生的上下文切換。
在單 CPU 系統中,多個進程看似“同時執行”的本質是通過分時共享(Time-Sharing)和上下文切換(Context Switching)實現的。操作系統將 CPU 時間劃分為微小的時間片(如 10-100ms),每個進程在時間片內獨占 CPU。時間片耗盡后,操作系統強制切換其他進程。雖然同一時刻 CPU 只能執行一個進程的指令,但操作系統通過快速輪換進程的執行權,讓用戶感知到多個進程的“并發運行”。
雖然切換過程有開銷,但合理的時間片大小和調度算法能平衡響應速度和吞吐量,為用戶提供“并行”的體驗。
(2)為何線程的上下文切換速度相對更快?線程間數據交換為何不需要類似 IPC 的特別技術?
同一進程內的線程共享虛擬地址空間(包括頁表、內存映射、文件描述符等),切換時只需保存/恢復寄存器、棧指針、程序計數器等少量 CPU 狀態,無需更新內存管理相關硬件。同一進程的線程共享代碼段、數據段和堆,切換后 CPU 緩存(Cache)和 TLB 的命中率更高,減少了因緩存失效導致的性能損失。
線程直接共享進程的全局變量、堆內存等,讀寫同一內存區域即可交換數據,無需內核介入或額外拷貝。
(3)請從執行流角度說明進程和線程的區別。
- 進程:在操作系統中構成單獨執行流的單位
- 線程:在進程內構成單獨執行流的單位
(4)下列關于臨界區的說法錯誤的是?
a. 臨界區是多個線程同時訪問時發生問題的區域。
b. 線程安全的函數不存在臨界區,即使多個線程同時調用也不會發生問題。
c. 1 個臨界區只能由 1 個代碼塊,而非多個代碼塊構成。換言之,線程 A 執行的代碼塊 A 和線程 B 執行的代碼塊 B 之間絕對不會構成臨界區。
d. 臨界區由訪問全局變量的代碼構成。其他變量不會發生問題。
答:
b、c、d。
(5)下列關于線程同步的描述錯誤的是?
a. 線程同步就是限制訪問臨界區。
b. 線程同步也具有控制線程執行順序的含義。
c. 互斥量和信號量是典型的同步技術。
d. 線程同步是代替進程 IPC 的技術。
答:
a、d。
(6)請說明完全銷毀 Linux 線程的 2 種辦法。
- 調用 pthread_join 函數
- 調用 pthread_detach 函數
第一個會阻塞調用的線程,而第二個不阻塞。這兩個函數都可以引導線程銷毀。
(7)請利用多線程技術實現回聲服務器端,但要讓所有線程共享保存客戶端消息的內存空間(char數組)。這么做只是為了應用本章的同步技術,其實不符合常理。
// 第十八章 習題 7
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <pthread.h>#define BUF_SIZE 100
void *handle_clnt(void *arg);
void error_handling(char *msg);char buf[BUF_SIZE];
pthread_mutex_t mutex;int main(int argc, char *argv[])
{int serv_sock, clnt_sock;struct sockaddr_in serv_adr, clnt_adr;int clnt_adr_sz;pthread_t t_id;if (argc != 2){printf("Usage : %s <port>\n", argv[0]);exit(1);}pthread_mutex_init(&mutex, NULL);serv_sock = socket(PF_INET, SOCK_STREAM, 0);memset(&serv_adr, 0, sizeof(serv_adr));serv_adr.sin_family = AF_INET;serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);serv_adr.sin_port = htons(atoi(argv[1]));if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)error_handling("bind() error");if (listen(serv_sock, 5) == -1)error_handling("listen() error");while (1){clnt_adr_sz = sizeof(clnt_adr);clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);pthread_create(&t_id, NULL, handle_clnt, (void *)&clnt_sock);pthread_detach(t_id);printf("Connected client IP: %s \n", inet_ntoa(clnt_adr.sin_addr));}close(serv_sock);return 0;
}void *handle_clnt(void *arg)
{int clnt_sock = *((int *)arg);int str_len = 0;while (1){pthread_mutex_lock(&mutex);str_len = read(clnt_sock, buf, sizeof(buf));if (str_len <= 0)break;write(clnt_sock, buf, str_len); // echopthread_mutex_unlock(&mutex);}close(clnt_sock);return NULL;
}void error_handling(char *msg)
{fputs(msg, stderr);fputc('\n', stderr);exit(1);
}
(8)上一題要求所有線程共享保存回聲消息的內存空間,如果采用這種方式,無論是否同步都會產生問題。請說明每種情況各產生哪些問題。
如果不同步,兩個以上線程會同時訪問同一內存空間,從而引發問題。
如果同步,由于 read 函數中調用了臨界區的參數,可能會發生無法讀取其他客戶端發送過來的字符串而必須等待的情況。