libuv小冊之線程池篇

最近開始寫小冊子,一篇篇來,寫完了再整理總結到一起。

Libuv是基於事件驅動的異步io庫,他本身是一個單進程單線程的。但是難免會有耗時的操作。如果在Libuv的主循環裏執行的話,就會阻塞後面的任務執行。所以Libuv裏維護了一個線程池。他負責處理Libuv中耗時的操作,比如文件io、dns、用戶自定義的耗時任務。文件io因爲存在跨平臺兼容的問題。無法很好地在事件驅動模塊實現異步io。下面分析一下線程池的實現。
我們先看線程池的初始化然後再看他的使用。

static void init_threads(void) {
  unsigned int i;
  const char* val;
  uv_sem_t sem;
  // 默認線程數4個,static uv_thread_t default_threads[4];
  nthreads = ARRAY_SIZE(default_threads);
  // 判斷用戶是否在環境變量中設置了線程數,是的話取用戶定義的
  val = getenv("UV_THREADPOOL_SIZE");
  if (val != NULL)
    nthreads = atoi(val);
  if (nthreads == 0)
	nthreads = 1;
  // #define MAX_THREADPOOL_SIZE 128最多128個線程
  if (nthreads > MAX_THREADPOOL_SIZE)
    nthreads = MAX_THREADPOOL_SIZE;
  
  threads = default_threads;
  // 超過默認大小,重新分配內存
  if (nthreads > ARRAY_SIZE(default_threads)) {
	threads = uv__malloc(nthreads * sizeof(threads[0]));
	// 分配內存失敗,回退到默認
    if (threads == NULL) {
      nthreads = ARRAY_SIZE(default_threads);
      threads = default_threads;
    }
  }
  // 初始化條件變量
  if (uv_cond_init(&cond))
    abort();
  // 初始化互斥變量
  if (uv_mutex_init(&mutex))
    abort();

  // 初始化三個隊列
  QUEUE_INIT(&wq);
  QUEUE_INIT(&slow_io_pending_wq);
  QUEUE_INIT(&run_slow_work_message);

  // 初始化信號量變量,值爲0
  if (uv_sem_init(&sem, 0))
    abort();
  // 創建多個線程,工作函數爲worker,sem爲worker入參
  for (i = 0; i < nthreads; i++)
    if (uv_thread_create(threads + i, worker, &sem))
      abort();
  //爲0則阻塞,非0則減一,這裏等待所有線程啓動成功再往下執行
  for (i = 0; i < nthreads; i++)
    uv_sem_wait(&sem);

  uv_sem_destroy(&sem);
}

線程池的初始化主要是初始化一些數據結構,然後創建多個線程。接着在每個線程裏執行worker函數。worker是消費者,在分析消費者之前,我們先看一下生產者的邏輯。

static void init_once(void) {
  init_threads();
}

// 給線程池提交一個任務
void uv__work_submit(uv_loop_t* loop,
                     struct uv__work* w,
                     enum uv__work_kind kind,
                     void (*work)(struct uv__work* w),
                     void (*done)(struct uv__work* w, int status)) {
  // 保證已經初始化線程,並只執行一次,所以線程池是在提交第一個任務的時候才被初始化
  uv_once(&once, init_once);
  w->loop = loop;
  w->work = work;
  w->done = done;
  post(&w->wq, kind);
}

這裏把業務相關的函數和任務完成後的回調函數封裝到uv__work結構體中。uv__work結構定義如下。

struct uv__work {
  void (*work)(struct uv__work *w);
  void (*done)(struct uv__work *w, int status);
  struct uv_loop_s* loop;
  void* wq[2];
};

然後調post往線程池的隊列中加入一個新的任務。Libuv把任務分爲三種類型,慢io(dns解析)、快io(文件操作)、cpu密集型等,kind就是說明任務的類型的。我們接着看post函數。

// 把任務插入隊列等待線程處理
static void post(QUEUE* q, enum uv__work_kind kind) {
  // 加鎖訪問任務隊列,因爲這個隊列是線程池共享的
  uv_mutex_lock(&mutex);
  // 類型是慢IO
  if (kind == UV__WORK_SLOW_IO) {
	/* 
		插入慢IO對應的隊列,llibuv這個版本把任務分爲幾種類型,
		對於慢io類型的任務,libuv是往任務隊列裏面插入一個特殊的節點
		run_slow_work_message,然後用slow_io_pending_wq維護了一個慢io任務的隊列,
		當處理到run_slow_work_message這個節點的時候,libuv會從slow_io_pending_wq
		隊列裏逐個取出任務節點來執行。 
	*/
    QUEUE_INSERT_TAIL(&slow_io_pending_wq, q);
    /*
      有慢IO任務的時候,需要給主隊列wq插入一個消息節點run_slow_work_message,
      說明有慢IO任務,所以如果run_slow_work_message是空,說明還沒有插入主隊列。
      需要進行q = &run_slow_work_message;賦值,然後把run_slow_work_message插入
      主隊列。如果run_slow_work_message非空,說明已經插入線程池的任務隊列了。
	解鎖然後直接返回。
    */
    if (!QUEUE_EMPTY(&run_slow_work_message)) {
      uv_mutex_unlock(&mutex);
      return;
	}
	// 說明run_slow_work_message還沒有插入隊列,準備插入隊列
    q = &run_slow_work_message;
  }
  // 把節點插入主隊列,可能是慢IO消息節點或者一般任務
  QUEUE_INSERT_TAIL(&wq, q);
  // 有空閒線程則喚醒他,如果大家都在忙,則等到他忙完後就會重新判斷是否還有新任務
  if (idle_threads > 0)
    uv_cond_signal(&cond);
  uv_mutex_unlock(&mutex);
}

這就是libuv中線程池的生產者邏輯。架構如下。


除了上面提到的,libuv還提供了另外一種生產者。即uv_queue_work函數。他只針對cpu密集型的。從實現來看,他和第一種生產模式的區別是,通過uv_queue_work提交的任務,是對應一個request的。如果該request對應的任務沒有執行完,則事件循環不會退出。而通過uv__work_submit方式提交的任務就算沒有執行完,也不會影響事件循環的退出。下面我們看uv_queue_work的實現。

int uv_queue_work(uv_loop_t* loop,
                  uv_work_t* req,
                  uv_work_cb work_cb,
                  uv_after_work_cb after_work_cb) {
  if (work_cb == NULL)
    return UV_EINVAL;

  uv__req_init(loop, req, UV_WORK);
  req->loop = loop;
  req->work_cb = work_cb;
  req->after_work_cb = after_work_cb;
  uv__work_submit(loop,
                  &req->work_req,
                  UV__WORK_CPU,
                  uv__queue_work,
                  uv__queue_done);
  return 0;
}

uv_queue_work函數其實也沒有太多的邏輯,他保存用戶的工作函數和回調到request中。然後提交任務,然後把uv__queue_work和uv__queue_done封裝到uv__work中,接着提交任務。所以當這個任務被執行的時候。他會執行工作函數uv__queue_work。

static void uv__queue_work(struct uv__work* w) {
  // 通過結構體某字段拿到結構體地址
  uv_work_t* req = container_of(w, uv_work_t, work_req);
  req->work_cb(req);
}

我們看到uv__queue_work其實就是對用戶定義的任務函數進行了封裝。這時候我們可以猜到,uv__queue_done也只是對用戶回調的簡單封裝,即他會執行用戶的回調。至此,我們分析完了libuv中,線程池的兩種生產任務的方式。下面我們開始分析消費者。消費者由worker函數實現。

static void worker(void* arg) {
  struct uv__work* w;
  QUEUE* q;
  int is_slow_work;
  // 線程啓動成功
  uv_sem_post((uv_sem_t*) arg);
  arg = NULL;
  // 加鎖互斥訪問任務隊列
  uv_mutex_lock(&mutex);
  for (;;) {
    /*
      1 隊列爲空,
	  2 隊列不爲空,但是隊列裏只有慢IO任務且正在執行的慢IO任務個數達到閾值
      則空閒線程加一,防止慢IO佔用過多線程,導致其他快的任務無法得到執行
    */
    while (QUEUE_EMPTY(&wq) ||
           (QUEUE_HEAD(&wq) == &run_slow_work_message &&
            QUEUE_NEXT(&run_slow_work_message) == &wq &&
            slow_io_work_running >= slow_work_thread_threshold())) {
      idle_threads += 1;
      // 阻塞,等待喚醒
      uv_cond_wait(&cond, &mutex);
	  // 被喚醒,開始幹活,空閒線程數減一 
      idle_threads -= 1;
    }
    // 取出頭結點,頭指點可能是退出消息、慢IO,一般請求
    q = QUEUE_HEAD(&wq);
    // 如果頭結點是退出消息,則結束線程
	if (q == &exit_message) {
      // 喚醒其他因爲沒有任務正阻塞等待任務的線程,告訴他們準備退出
      uv_cond_signal(&cond);
      uv_mutex_unlock(&mutex);
      break;
    }
    // 移除節點 
	QUEUE_REMOVE(q);
	// 重置前後指針
    QUEUE_INIT(q);  
    is_slow_work = 0;
	/* 
		如果當前節點等於慢IO節點,上面的while只判斷了是不是隻有慢io任務且達到
		閾值,這裏是任務隊列裏肯定有非慢io任務,可能有慢io,如果有慢io並且正在	執行的個數達到閾值,則先不處理該慢io任務,繼續判斷是否還有非慢io任務可
		執行。
	*/
    if (q == &run_slow_work_message) { 
      // 遇到閾值,重新入隊 
      if (slow_io_work_running >= slow_work_thread_threshold()) {
        QUEUE_INSERT_TAIL(&wq, q);
        continue;
      }
      // 沒有慢IO任務則繼續
      if (QUEUE_EMPTY(&slow_io_pending_wq))
        continue;
      // 有慢io,開始處理慢IO任務
      is_slow_work = 1;
      // 正在處理慢IO任務的個數累加,用於其他線程判斷慢IO任務個數是否達到閾值
      slow_io_work_running++;
      // 摘下一個慢io任務
      q = QUEUE_HEAD(&slow_io_pending_wq);
      QUEUE_REMOVE(q);
      QUEUE_INIT(q);
       /*
		 取出一個任務後,如果還有慢IO任務則把慢IO標記節點重新入隊,
		表示還有慢IO任務,因爲上面把該標記節點出隊了 
	   */
      if (!QUEUE_EMPTY(&slow_io_pending_wq)) {
        QUEUE_INSERT_TAIL(&wq, &run_slow_work_message);
        // 有空閒線程則喚醒他,因爲還有任務處理
        if (idle_threads > 0)
          uv_cond_signal(&cond);
      }
    }
    // 不需要操作隊列了,儘快釋放鎖
    uv_mutex_unlock(&mutex);
    // q是慢IO或者一般任務
    w = QUEUE_DATA(q, struct uv__work, wq);
    // 執行業務的任務函數,該函數一般會阻塞
    w->work(w);
    // 準備操作loop的任務完成隊列,加鎖
	uv_mutex_lock(&w->loop->wq_mutex);
	// 置空說明指向完了,不能被取消了,見cancel邏輯
    w->work = NULL;  
    // 執行完任務,插入到loop的wq隊列,在uv__work_done的時候會執行該隊列的節點
    QUEUE_INSERT_TAIL(&w->loop->wq, &w->wq);
    // 通知loop的wq_async節點
    uv_async_send(&w->loop->wq_async);
	uv_mutex_unlock(&w->loop->wq_mutex);
	// 爲下一輪操作任務隊列加鎖
    uv_mutex_lock(&mutex);
    // 執行完慢IO任務,記錄正在執行的慢IO個數變量減1,上面加鎖保證了互斥訪問這個變量
    if (is_slow_work) {
      slow_io_work_running--;
    }
  }
}

我們看到消費者的邏輯似乎比較複雜,主要是把任務分爲三種。並且對於慢io類型的任務,還限制了線程數。其餘的邏輯和一般的線程池類型,就是互斥訪問任務隊列,然後取出節點執行,最後執行回調。不過libuv這裏不是直接回調用戶的函數。而是通過uv_async_send(&w->loop->wq_async)通知主進程有任務完成了。然後線程繼續執行任務。我們看一下這個函數的實現。

// 通知主線程有任務完成
int uv_async_send(uv_async_t* handle) {
  /*
	1 pending是0,則設置爲1,返回0,
	2 pending是1則返回1,
	所以同一個async如果多次調用該函數是會被合併的。只有pending等於
	0的時候纔會執行uv__async_send
*/
  if (cmpxchgi(&handle->pending, 0, 1) == 0)
    uv__async_send(handle->loop);

  return 0;
}

static void uv__async_send(uv_loop_t* loop) {
  const void* buf;
  ssize_t len;
  int fd;
  int r;

  buf = "";
  len = 1;
  // 用於異步通信的管道的寫端
  fd = loop->async_wfd;

#if defined(__linux__)
  // fd等於1說明用的是eventfd而不是管道,管道纔有兩端
  if (fd == -1) {
    static const uint64_t val = 1;
    buf = &val;
    len = sizeof(val);
    // 見uv__async_start
    fd = loop->async_io_watcher.fd;  /* eventfd */
  }
#endif
  // 通知讀端
  do
    r = write(fd, buf, len);
  while (r == -1 && errno == EINTR);
	// 省略部分代碼
}

uv__async_send通過網eventfd中寫入一些數據,觸發了對應io觀察者的事件。之前在分析async機制的時候講過。該io觀察者的回調是uv__work_done函數。那麼我們就看看這個函數的邏輯。

void uv__work_done(uv_async_t* handle) {
  struct uv__work* w;
  uv_loop_t* loop;
  QUEUE* q;
  QUEUE wq;
  int err;
  // 通過結構體字段獲得結構體首地址
  loop = container_of(handle, uv_loop_t, wq_async);
  // 準備處理隊列,加鎖
  uv_mutex_lock(&loop->wq_mutex);
  // 把loop->wq隊列的節點全部移到wp變量中,這樣一來可以儘快釋放鎖
  QUEUE_MOVE(&loop->wq, &wq);
  // 不需要使用了,解鎖
  uv_mutex_unlock(&loop->wq_mutex);
  // wq隊列的節點來源是在線程的worker裏插入
  while (!QUEUE_EMPTY(&wq)) {
    q = QUEUE_HEAD(&wq);
    QUEUE_REMOVE(q);

    w = container_of(q, struct uv__work, wq);
    err = (w->work == uv__cancelled) ? UV_ECANCELED : 0;
    // 執行回調
    w->done(w, err);
  }
}

逐個處理已完成的任務節點,執行回調。這就是整個消費者的邏輯。最後順帶提一下w->work == uv__cancelled。這個處理的用處是爲了支持取消一個任務。Libuv提供了uv__work_cancel函數支持用戶取消提交的任務。我們看一下他的邏輯。

static int uv__work_cancel(uv_loop_t* loop, uv_req_t* req, struct uv__work* w) {
  int cancelled;
  // 加鎖,爲了把節點移出隊列
  uv_mutex_lock(&mutex);
  // 加鎖,爲了判斷w->wq是否爲空
  uv_mutex_lock(&w->loop->wq_mutex);
  /*
    w在在任務隊列中並且任務函數work不爲空,則可取消,
    在work函數中,如果執行完了任務,會把work置NULL,
    所以一個任務可以取消的前提是他還沒執行完。或者說還沒執行過
  */
  cancelled = !QUEUE_EMPTY(&w->wq) && w->work != NULL;
  // 從任務隊列中刪除該節點
  if (cancelled)
    QUEUE_REMOVE(&w->wq);

  uv_mutex_unlock(&w->loop->wq_mutex);
  uv_mutex_unlock(&mutex);
  // 不能取消
  if (!cancelled)
    return UV_EBUSY;
  // 重置回調函數
  w->work = uv__cancelled;
 
  uv_mutex_lock(&loop->wq_mutex);
   /*
     插入loop的wq隊列,對於取消的動作,libuv認爲是任務執行完了。
     所以插入已完成的隊列,不過他的回調是uv__cancelled函數,
     而不是用戶設置的回調
   */
  QUEUE_INSERT_TAIL(&loop->wq, &w->wq);
  // 通知主線程有任務完成
  uv_async_send(&loop->wq_async);
  uv_mutex_unlock(&loop->wq_mutex);

  return 0;
}

最後我們舉一個使用線程池的例子。這裏以文件操作爲例子,因爲nodejs中文件讀寫是以線程池實現的。這裏直接從uv_fs_open開始(因爲js層到c++層主要是一些封裝。最後會調到uv_fs_open)。直接看一下uv_fs_open的代碼。

// 下面代碼是宏展開後的效果
int uv_fs_open(
	uv_loop_t* loop,
         uv_fs_t* req,
         const char* path,
         int flags,
         int mode,
         uv_fs_cb cb
) {                                                                 
    // 初始化一些字段
	UV_REQ_INIT(req, UV_FS);                                                  
    req->fs_type = UV_FS_ ## subtype;                                         
    req->result = 0;                                                          
    req->ptr = NULL;                                                          
    req->loop = loop;                                                         
    req->path = NULL;                                                         
    req->new_path = NULL;                                                     
    req->bufs = NULL;                                                         
	req->cb = cb;    
	// 同步                                                
    if (cb == NULL) {                                                         
      req->path = path;                                                       
    } else {                                                                  
      req->path = uv__strdup(path);                               
    } 
    req->flags = flags;
    req->mode = mode;                                                                     
    if (cb != NULL) {                                                         
      uv__req_register(loop, req);  
      /* 異步*/                                           
      uv__work_submit(
		 loop,                                                   
         &req->work_req,                                         
         UV__WORK_FAST_IO,                                       
         uv__fs_work,                                            
         uv__fs_done	
	  );                                           
      return 0;                                                               
    }                                                                         
    else {                 
       /* 同步 */                                                    
      uv__fs_work(&req->work_req);                                            
      return req->result;                                                     
    }   

我們從上往下看,沒有太多的邏輯,函數的最後一個參數cb是nodejs的c++層設置的,c++層會再回調js層。然後open(大部分的文件操作)分爲同步和異步兩種模式(即fs.open和openSync)。同步直接導致nodejs阻塞,不涉及到線程池,這裏只看異步模式。我們看到異步模式下是調用uv__work_submit函數給線程池提交一個任務。設置的工作函數和回調函數分別是uv__fs_work,uv__fs_done。所以我們看一下這兩函數。uv__fs_work函數主要是調用操作系統提供的函數。比如open。他會引起線程的阻塞,等到執行完後,他會把返回結果保存到request結構體中。接着執行就是遵從線程池的處理流程。執行回調uv__fs_done。

static void uv__fs_done(struct uv__work* w, int status) {
  uv_fs_t* req;

  req = container_of(w, uv_fs_t, work_req);
  uv__req_unregister(req->loop, req);
  // 取消了
  if (status == UV_ECANCELED) {
    req->result = UV_ECANCELED;
  }
  // 執行用戶設置的回調,比如nodejs
  req->cb(req);
}

沒有太多邏輯,直接執行回調,順便提一下,nodejs裏則是執行c++層函數AfterInteger(代碼在node_file.cc的Open函數)。

void AfterInteger(uv_fs_t* req) {
  FSReqWrap* req_wrap = static_cast<FSReqWrap*>(req->data);
  FSReqAfterScope after(req_wrap, req);

  if (after.Proceed())
    req_wrap->Resolve(Integer::New(req_wrap->env()->isolate(), req->result));
}

void FSReqWrap::Resolve(Local<Value> value) {
  Local<Value> argv[2] {
    Null(env()->isolate()),
    value
  };
  MakeCallback(env()->oncomplete_string(), arraysize(argv), argv);
}

執行resolve,然後執行js層的oncomplete回調,即用戶執行open函數時傳入的函數。至此,線程池分析完成。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章