【從0開始編寫webserver·基礎篇#01】為什麼需要執行緒池?寫一個執行緒池吧

dayceng發表於2023-05-14

執行緒池

參考:

1、遊雙Linux高效能伺服器程式設計

2、TinyWebServer

注:雖然是"從0開始",但最好對(多)執行緒、執行緒同步等知識點有所瞭解再看,不然可能有些地方會理解不到位(但也有可能是我沒說明到位,水平有限,見諒)

Web伺服器與執行緒池的關係

Web伺服器需要同時處理多個客戶端請求,並且每個請求可能需要花費很長時間來處理,如資料庫查詢、檔案讀寫等操作。

因此Web伺服器不太可能是單執行緒的,要實現併發操作就必須引入多執行緒技術

使用執行緒池的伺服器屬於多執行緒伺服器。執行緒池本質上是一種多執行緒技術,透過在程式啟動時建立一定數量的工作執行緒,並將所有請求任務加入到任務佇列中,以便執行緒池中的多個工作執行緒可以同時處理請求任務。

在Web伺服器中,執行緒池的作用是有效地處理併發請求,提高伺服器的併發能力和效能

具體地,Web伺服器透過執行緒池實現以下幾個方面的功能:

  1. 提高併發效能:執行緒池可以在同一時刻處理多個請求,從而提高伺服器的併發處理能力,減少請求響應時間。
  2. 控制執行緒數量:執行緒池可以控制同時執行的執行緒數量,從而避免執行緒數目過多導致系統資源耗盡。
  3. 管理執行緒狀態:執行緒池可以對工作執行緒進行管理,並監測工作執行緒的狀態,如是否空閒、是否存活等。
  4. 避免執行緒建立銷燬開銷:使用執行緒池可以避免頻繁地建立和銷燬執行緒,從而減少系統開銷,提高伺服器的效能。

實現一個執行緒池

前置知識:互斥鎖、訊號量、C++模板程式設計

(注:程式碼中使用的)

執行緒池在程式碼實現上是一個模板類,使用模板的原因是方便進行復用

螢幕截圖 2023-05-14 105832

執行緒池模板類,主要用於管理多個執行緒並處理任務。其中包含以下主要成員變數和函式:

  • 成員變數:執行緒數量(m_thread_number)、最大請求數(m_max_requests)、執行緒池陣列(m_threads)、請求佇列(m_workqueue)、互斥鎖(m_queuelocker)、訊號量(m_queuestat)和是否停止標誌(m_stop)。
  • 成員函式:建構函式(threadpool())、解構函式(~threadpool())、新增任務(append())、子執行緒中要執行的工作函式(worker())和啟動執行緒池中的一個或多個執行緒進行任務處理的函式(run())。

下面是該程式碼的工作流程和原理:

​ 1、建立執行緒池

建立時,會先判斷執行緒數和最大請求數是否小於等於0。之後,會建立一個大小為 m_thread_number 的執行緒池陣列,並將其設定為執行緒脫離狀態,即在建立完執行緒之後就可以將其與當前的程式分離,避免執行緒阻塞主執行緒和其他相關執行緒的執行。如果建立時失敗,則丟擲異常。

​ 2、新增任務

往任務佇列中新增請求時,會先加鎖(使用互斥鎖),以確保多個程式不會爭搶。之後,會判斷任務佇列中的請求數是否超過 m_max_requests,如果佇列大小大於最大請求數,則解鎖並返回 false。否則,將請求新增到佇列中,解鎖並增加訊號量,通知執行緒池中的執行緒,有新任務需要處理。最終返回 true。

​ 3、執行緒工作函式

當收到訊號量後,執行緒會先上鎖(使用互斥鎖),以確保多個執行緒不會同時訪問佇列。之後,會判斷任務佇列是否為空。如果佇列為空,則解鎖並繼續等待下一次訊號量的到來。否則,取出佇列頂部的請求,並將其彈出佇列。最後,解鎖,並呼叫任務函式(request->process())。

​ 4、解構函式

執行解構函式時,將 m_stop 設定為 true,供執行緒判斷是否要停止。

定義執行緒池類

先來定義一下執行緒池類

template<typename T> threadpool {
private:
    int m_thread_number;//執行緒數
    int m_max_requests;//最大請求數
    bool m_stop;//停止符
    pthread_t* m_thread;//執行緒池陣列
    std::list<T*> m_workqueue;//任務佇列
    locker queuelocker;//互斥鎖,locker是對系統提供的mutex方法的封裝,位於locker.h中
    sem m_queuestat;//訊號量
private:
    static void* worker(void* arg);//工作函式
    void run();		//執行緒池的主函式,用於檢查任務佇列中的請求
    
public:
    threadpool(int m_thread_number = 8, int m_max_requests = 1000);//構造
    ~threadpool();//析構
    bool append();//將請求加入m_workqueue中   	
}

其實也沒有很複雜,但是有以下幾個點需要注意:

1、執行緒池陣列m_thread和任務佇列m_workqueue是沒有直接聯絡的

線上程池類初始化時,執行緒池陣列就會根據指定的 執行緒數m_thread_number 來建立對應數量的執行緒

這些執行緒會被阻塞(一直呼叫 run函式 檢查 m_workqueue 中有無請求),直到 **外部呼叫執行緒池物件的程式碼 **透過呼叫threadpool<T>類的物件的append()方法 向任務佇列中新增新的任務

這時,append()才會定義模板類中宣告的 任務佇列m_workqueue(也就是往佇列中push請求任務)

此時,之前被建立的某個執行緒檢查到佇列中有任務,於是其獲取到了鎖

將佇列中的請求彈出,呼叫對應的任務函式進行處理

綜上,雖然執行緒池陣列和任務佇列沒有直接聯絡,但是他們具有協作關係,實現伺服器對於請求的監聽和處理操作

2、工作函式為什麼要定義為靜態的,並且其返回值為什麼是void*

在C語言中,函式的返回值只能是一個型別。但是有些時候,我們需要從一個函式中返回多個值或者不同型別的值

在這種情況下,我們可以使用指標或者void指標來達到這個目的。

在這裡,worker被定義為static void*,其中void*表示一個指向記憶體地址的指標,但是它沒有指定具體的資料型別。

這意味著worker函式可以返回任何型別的指標,這使得worker函式具有更大的靈活性,並且可以處理各種不同型別的資料

同時,透過將worker函式宣告為static,它只能在當前檔案中使用,可以避免與其他檔案中的函式名稱重複的問題。

好了,執行緒池模板類定義完了,現在需要在類外分別實現各個成員函式

按順序來:建構函式->解構函式->...

實現執行緒池建構函式

建構函式負責根據給定的thread_number來建立執行緒

首先,我們需要判斷所給的引數範圍是否合法

然後建立執行緒池陣列,大小為thread_number,最後呼叫pthread_create函式建立執行緒,使用pthread_detach函式將執行緒設定為脫離狀態

template<typename T> //透過引數列表進行初始化
threadpool<T>::threadpool(int thread_number, int max_requests):m_thread_number(thread_number),m_max_requests(max_requests),
    m_stop(false), m_threads(NULL){
    //異常判斷,執行緒數和最大請求數小於0,報錯
        if((thread_number <= 0) || (max_requests <= 0)){
            throw std:: exception();
        }
        m_threads = new pthread_t[m_thread_number];//建立執行緒池陣列
        if(!m_threads){
            throw std:: exception();
        }
        for(int i = 0; i < thread_number; ++i){
            printf("建立第 %d 個執行緒\n", i);
            if(pthread_create(m_threads + i, NULL, worker, this) != 0){
                delete[] m_threads;
                throw std::exception();//建立失敗
            }
            if(pthread_detach(m_threads[i])){//在呼叫pthread_detach()函式之後,執行緒將進入“分離”狀態,這意味著它不能再被其他執行緒或主執行緒等待和加入。
            }
        }     
} 

從建構函式可知,執行緒是線上程池建立時就被建立的,並且數量是固定的

有以下注意點:

1、pthread_create函式的傳入引數

pthread_create()函式需要四個輸入引數,分別是:

1.執行緒識別符號指標(pthread_t *),用於儲存新建立執行緒的識別符號;

2.執行緒屬性指標(const pthread_attr_t *),用於設定新執行緒的屬性。如果不需要設定,則可以將該引數設定為NULL;

3.指向函式的指標(void (start_routine) (void *)),用於作為新執行緒的入口點。新執行緒開始執行時會從該函式開始執行;

4.傳遞給新執行緒入口點函式的引數指標(void *),該引數可以是任意型別的指標,它會被傳遞給新執行緒入口點函式。

在本程式碼中,pthread_create()函式的第一個引數是一個pthread_t型別的指標,該指標用於儲存新建立執行緒的識別符號。

第二個引數設定為NULL,因為我們不需要設定新執行緒的屬性。

第三個引數是一個指向worker函式的指標,作為新執行緒的入口點。

最後一個引數是一個指向當前threadpool物件的指標,它被傳遞給worker函式作為引數,讓worker函式能夠訪問到threadpool物件的所有

成員。

m_threads + i 表示將 m_threads指標 向後偏移 i 個 pthread_t型別的長度,即指向執行緒池中第i個工作執行緒的識別符號。

m_threads 是一個指向pthread_t型別的陣列,當使用 m_threads[i] 時,實際上是對m_threads陣列中第i個元素進行訪問

因此,m_threads + i 表示對 m_threads陣列 進行偏移,使其指向第i個元素的地址。

pthread_create()函式中,需要傳遞一個指向執行緒識別符號的指標作為引數,來儲存新建執行緒的識別符號。

因此,可以使用 m_threads + i 作為該引數,表示將指向第i個工作執行緒的識別符號的地址傳遞給pthread_create()函式。

worker是一個靜態成員函式,它作為執行緒執行的入口點,用於處理任務佇列中的請求

this指標是一個指向當前threadpool物件的指標,它被傳遞給worker函式作為引數。

由於worker函式是靜態的,因此無法訪問threadpool物件的非靜態成員。

(為了防止重名所以設為靜態)

因此,需要將threadpool物件的指標作為引數傳遞給worker函式,以便讓worker函式能夠訪問threadpool物件的所有成員。

在pthread_create()函式中,需要將worker函式的指標作為引數傳遞,而this指標則用於向worker函式傳遞threadpool物件的指標。

透過這種方式,就可以讓worker函式訪問到threadpool物件的所有成員變數和成員函式。

解構函式

主要作用就是停止執行緒池。刪除執行緒池陣列,並回收資源

//實現解構函式  
template<typename T>
threadpool<T>::~threadpool(){
    delete[] m_threads;//用完之後就把執行緒池陣列刪除
    m_stop = true;//執行解構函式時將其置為true,供執行緒判斷是否要停止
}

實現工作函式worker

從上面的分析可知,worker函式 會線上程池初始化時被建構函式呼叫

template<typename T>
void* threadpool<T>::worker(void* arg){
    threadpool* pool = (threadpool* )arg;
    pool->run();
    return pool;
}

該函式主要做的事情就是就是接收一個執行緒池物件

具體來說, worker函式 從 pthread_create()函式 中得到了一個void型別的指標作為輸入引數。

這個指標可以透過 pthread_create() 的最後一個引數(也就是 this )進行傳遞,即執行緒建立時呼叫的arg引數。

什麼意思呢?

我們要呼叫pthread_create()函式建立一個執行緒,此時我們需要提供執行緒的儲存位置執行緒屬性引數新執行緒的入口函式以及該入口函式所需的引數的指標

在建立執行緒時,需要傳入一個入口函式的指標,用於告訴作業系統新執行緒應該從哪裡開始執行程式程式碼。在這裡,我們將worker函式作為入口函式,以便啟動一個新執行緒,並且讓該執行緒執行執行緒池的工作函式run()。

也就是說,我們在例項化一個執行緒池類的時候(假設為A),同時也把一個指向A的指標作為引數傳給了worker函式(透過pthread_create()函式),worker函式再呼叫A中的成員函式run()用於處理任務

由於run()函式被定義為非靜態成員函式,無法直接作為入口函式使用,因此我們選擇了worker函式作為入口函式,並在其中呼叫run()函式。在C++中,可以將任何函式作為新執行緒的入口函式,只要它符合執行緒函式的格式要求(即返回值為void*型別,引數為void*型別)。在實際程式設計中,通常會選擇一個適當的函式作為入口函式,以實現所需的功能。

實現任務處理函式run

新執行緒以worker函式為入口進行執行後,會透過指標去呼叫run()函式,透過迴圈不斷檢查任務佇列來獲取任務(如果有的話)

取到任務之後會呼叫相應的處理函式進行處理(這裡是process(),還沒定義)

template<typename T>
void threadpool<T>::run(){
    while(!m_stop){
        //阻塞等待捕獲sem訊號量
        m_queuestat.wait();
        
        //拿到訊號量之後上鎖
        m_queuelocker.lock();
        
        if(m_workqueue.empty()){
            m_queuelocker.unlock();//若佇列為空就解鎖
            continue;
        }
        //取出佇列頭部的請求
        T* request = m_workqueue.front();
        m_workqueue.pop_front();
        m_queuelocker.unlock();//解鎖
        
        if(!request) continue;//沒有東西就繼續迴圈
        request->process();//有就呼叫對應的處理函式
    }
}

這裡用到了互斥鎖與訊號量

上鎖是為了保證在多執行緒的情況下,不會出現資源爭奪的情況,保證執行緒安全

訊號量的使用則涉及到了伺服器整體的設計結構,後面再說(面試重點

實現新增任務函式append

到目前為止,一個所謂的"執行緒池"已經基本完工

這個"池"可以建立一個陣列存放建立好的執行緒物件,並維護一個任務佇列,從佇列中不斷檢查是否有新任務(外界對伺服器的請求)到來

從程式碼來看,run函式只有在收到訊號量時才會去檢查佇列

那麼是誰負責傳送(改變)訊號量呢?那肯定是負責將任務加入佇列的那個部分,也就是append函式了

template<typename T>
void threadpool::append(T* request){
    //觸發append就意味著有新請求來了,此時需要有執行緒來處理,所以為了安全要上鎖
    m_queuelocker.lock();
    //判斷當前佇列中的任務是否已經達到最大請求上限
    if(m_workqeue.size() > m_max_requests){
        m_queuelocker.unlock();//是就解鎖
        return false;//新增失敗
    }
    m_workqueue.push_back(request);//往任務佇列新增一個請求
    m_queuelocker.unlock();
    
    m_queuestat.post();//修改訊號量
    return true;
    
}

在向佇列新增任務後,append修改了訊號量m_queuestat,使得阻塞在wait()處的run函式開始檢查佇列,獲取剛被加入到佇列中的任務

至此,執行緒池的全部功能實現完畢

完整程式碼

threadpool.h
#ifndef THREADPOOL_H 
#define THREADPOOL_H

#include <pthread.h>
#include <list>
#include <stdio.h>
#include "locker.h"

//執行緒池類,將其定義為模板類是為了程式碼的複用
//模板引數T就是任務類
template<typename T>
class threadpool {
private:
    //執行緒數量
    int m_thread_number;
    //執行緒池陣列,大小為m_thread_number
    pthread_t * m_threads;//使用pthread_t一是為了效能,二是為了執行緒安全(相對於vector來說)
    //請求佇列中最多允許的待處理請求數
    int m_max_requests;
    //請求佇列
    std::list<T*> m_workqueue;
    //互斥鎖
    locker m_queuelocker;
    //訊號量,用於判斷是否有任務需要處理
    sem m_queuestat;
    //是否結束執行緒
    bool m_stop;

private:
    //子執行緒中要執行的程式碼
    static void* worker(void* arg);
    void run();

public:
    threadpool(int thread_number = 8, int max_request = 10000);
    ~threadpool();

    bool append(T* request);
};
//模板外實現執行緒池建構函式
template<typename T>//引數列表初始化
threadpool<T>::threadpool(int thread_number, int max_requests):
    m_thread_number(thread_number),m_max_requests(max_requests),
    m_stop(false), m_threads(NULL){
        //異常判斷,執行緒數和最大請求數小於0,報錯
        if((thread_number <= 0) || (max_requests <= 0)){
            throw std:: exception();
        }

        m_threads = new pthread_t[m_thread_number];//建立執行緒池陣列
        if(!m_threads){
            throw std:: exception();
        }
        //建立thread_number個執行緒,並將它們設定為執行緒脫離
        //執行緒脫離指的是在一個多執行緒程式中,某個執行緒完成了它原本需要執行的任務之後,
        //並不立即結束自己的執行,而是繼續保持執行狀態,直到其他執行緒也完成了它們的任務之後才退出。
        //這種情況下,該執行緒被稱為“脫離執行緒”(detached thread)
        /*執行緒脫離通常用於需要長時間執行的後臺任務,透過將這些任務單獨分配給脫離執行緒來處理,可以避免阻塞主執行緒和其他相關執行緒的執行。*/
        for(int i = 0; i < thread_number; ++i){
            printf("建立第 %d 個執行緒\n", i);
            //C++裡面的woker是靜態的,所以要傳入this來訪問類裡變數
            /*在C++中,對指標進行加減操作會根據指標型別的大小進行調整。
            因此,m_threads + i表示將m_threads指標向後偏移i個pthread_t型別的長度,即指向執行緒池中第i個工作執行緒的識別符號。
            m_threads是一個指向pthread_t型別的陣列,當使用m_threads[i]時,實際上是對m_threads陣列中第i個元素進行訪問。
            m_threads + i表示對m_threads陣列進行偏移,使其指向第i個元素的地址。
            在pthread_create()函式中,需要傳遞一個指向執行緒識別符號的指標作為引數,來儲存新建執行緒的識別符號。
            因此,可以使用m_threads + i作為該引數,表示將指向第i個工作執行緒的識別符號的地址傳遞給pthread_create()函式。*/
            if(pthread_create(m_threads + i, NULL, worker, this) != 0){//為了讓worker訪問非靜態成員,傳入this
                delete[] m_threads;
                throw std::exception();//建立失敗
            }
            if(pthread_detach(m_threads[i])){//在呼叫pthread_detach()函式之後,執行緒將進入“分離”狀態,這意味著它不能再被其他執行緒或主執行緒等待和加入。
            }
        }
    }

//實現解構函式  
template<typename T>
threadpool<T>::~threadpool(){
    delete[] m_threads;//用完之後就把執行緒池陣列刪除
    m_stop = true;//執行解構函式時將其置為true,供執行緒判斷是否要停止
}

//實現append
template<typename T>
bool threadpool<T>::append(T* request){//往佇列中新增任務,要保證執行緒同步
    m_queuelocker.lock();//新增互斥鎖
    if(m_workqueue.size() > m_max_requests){//任務佇列大小大於最大請求數
        m_queuelocker.unlock();//解鎖並報錯,此時的任務數已經超出上限
        return false;
    }

    m_workqueue.push_back(request);//往佇列中增加一個請求
    m_queuelocker.unlock();//解鎖
    //將請求加入工作佇列的操作是需要保證其原子性的,因此需要互斥鎖保證多個程式不會爭搶
    m_queuestat.post();//增加訊號量,通知執行緒池中的執行緒,有新任務需要處理
    return true;
    /*當一個新的任務被新增到佇列中時,會呼叫 m_queuestat.post() 增加訊號量。
    線上程池初始化時,每個工作執行緒都被建立並阻塞在 m_queuestat.wait() 上等待訊號量的觸發。
    一旦 m_queuestat 的值大於 0,其中的一個執行緒就會從阻塞狀態喚醒並開始處理佇列中的請求。*/
}

template<typename T>//執行緒池的工作函式,其中模板引數T未被使用。該函式是作為新執行緒啟動時呼叫的入口函式
void* threadpool<T>::worker(void* arg){
    // 傳入void 型別指標 arg 
    /*arg 是在啟動執行緒時傳遞給該執行緒函式的引數。
    以下程式碼中,它被轉換為 threadpool* 型別,因為它實際上是一個指向 threadpool 結構體的指標。
    然後,將這個指標賦值給名為 pool 的變數,以便在該函式中訪問和操作 threadpool 結構體的成員。*/
    threadpool* pool = (threadpool* ) arg;//在pthread_create中傳入worker
    pool->run();//啟動執行緒池中的一個或多個執行緒,並將待處理任務提交給執行緒池進行處理
    return pool;
}

template<typename T>
void threadpool<T>::run(){
    while(!m_stop){
        m_queuestat.wait();//等待append函式傳過來的訊號量,收到表示需要執行執行緒池,使用其中的執行緒處理來處理任務
        //可能有資料到了,上鎖
        /*關於為什麼這裡要上鎖:
            收到訊號量時,任務佇列 m_workqueue 可能為空,也可能不為空,這取決於在等待訊號量之前是否有新任務被新增到了佇列中。
            如果沒有新任務被新增,那麼 m_workqueue 仍然為空。如果有新任務被新增,那麼 m_workqueue 將不為空。
            需要注意的是,在多執行緒程式設計中,一個執行緒在等待訊號量時,另一個執行緒可能會往任務佇列中新增新任務,因此需要透過加鎖(比如互斥鎖)來保證對任務佇列的訪問是執行緒安全的。
            這樣可以避免出現競態條件(race condition,也就是執行緒不同步),從而確保程式的正確性。
        */
        m_queuelocker.lock();
        /*在等待訊號量的執行緒執行之前,如果沒有任何其他執行緒向任務佇列中新增新的任務,那麼收到訊號量時 m_workqueue 可能為空。這種情況可以出現在以下幾種情況下:
            在初始化程式時,建立了一個空的任務佇列並等待訊號量,此時 m_workqueue 為空。
            所有的任務都已經被處理完畢,並且等待訊號量的執行緒尚未收到新的任務新增進來。
            等待訊號量的執行緒剛剛完成了處理該任務佇列中的所有任務,然後又立即等待訊號量,此時 m_workqueue 為空。
        */
        if(m_workqueue.empty()){
            m_queuelocker.unlock();//解鎖
            continue;//繼續迴圈,檢視佇列中是否有資料
        }
        //取出佇列頂部的請求,並將其彈出佇列
        T* request = m_workqueue.front();
        m_workqueue.pop_front();
        //取完請求後,解鎖
        m_queuelocker.unlock();

        if(!request){
            continue;//沒獲取到就繼續迴圈
        }
        //呼叫任務函式
        request->process();
    }
}
#endif
locker.h
#ifndef LOCKER_H //沒定義就定義一個LOCKER_H
#define LOCKER_H

#include <pthread.h>//互斥鎖相關
#include <exception>
#include <semaphore.h>//訊號量相關
//執行緒頭部機制的封裝類
//互斥鎖類
class locker{
private:
    pthread_mutex_t m_mutex;//建立一個互斥鎖
public:
    locker(){//建構函式
        if(pthread_mutex_init(&m_mutex, NULL) != 0){//初始化一個互斥鎖,預設屬性
            throw std::exception();//丟擲異常
        }
    }
    ~locker(){//解構函式,銷燬
        pthread_mutex_destroy(&m_mutex);
    }
    
    bool lock(){//上鎖
        return pthread_mutex_lock(&m_mutex) == 0;//判斷當前執行緒是否成功獲取到了互斥鎖 m_mutex。如果返回值為0,則表示當前執行緒已經成功獲取到了該互斥鎖;如果返回值不為0,則表示當前執行緒未能獲取到該互斥鎖。
    }
    bool unlock(){
        return pthread_mutex_unlock(&m_mutex) == 0;
    }
    pthread_mutex_t * get(){//獲取互斥量
        return &m_mutex;
    }
};
//條件變數類
//判斷佇列中有無資料,沒有就讓執行緒停著,有就喚醒執行緒
class cond {
private:
    pthread_cond_t m_cond;//建立一個條件變數
public:
    cond(){//建構函式
        if (pthread_cond_init(&m_cond, NULL) != 0) {
            throw std::exception();
        }
    }
    ~cond() {//解構函式
        pthread_cond_destroy(&m_cond);
    }
    
    bool wait(pthread_mutex_t *m_mutex) {
        int ret = 0;
        ret = pthread_cond_wait(&m_cond, m_mutex);
        return ret == 0;
    }
    bool timewait(pthread_mutex_t *m_mutex, struct timespec t) {//超時
        int ret = 0;
        ret = pthread_cond_timedwait(&m_cond, m_mutex, &t);
        return ret == 0;
    }
    bool signal() {//喚醒一個或多個執行緒
        return pthread_cond_signal(&m_cond) == 0;
    }
    bool broadcast() {//喚醒所有執行緒
        return pthread_cond_broadcast(&m_cond) == 0;
    }
};
//訊號量類
class sem{
private:
    sem_t m_sem;
public:
    sem(){
        if(sem_init(&m_sem, 0, 0) != 0){
            throw std:: exception();
        }
    }
    ~sem(){
        sem_destroy(&m_sem);
    }
    //等待訊號量
    bool wait(){
        return sem_wait(&m_sem) == 0;
    }
    //增加訊號量
    bool post(){
        return sem_post(&m_sem) == 0;
    }
};
#endif

相關文章