多執行緒在打包工具中的運用

袋鼠云数栈前端發表於2024-10-31

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:UED 團隊

現代作業系統都是「多工」的,也就是作業系統可以「併發」處理多個任務,比如可以在瀏覽頁面的時候同時播放音樂。但是,一般來說我們的 PC 只有一個物理 CPU ,那麼它是如何做到在只有一個 CPU 的情況下,併發處理多個任務的呢?我們簡單探究一下。

前置知識

我們先簡單熟悉一下 CPU 硬體相關的術語:

  • Sockets(physical CPU): 物理CPU,指我們主機板上實際插入的CPU,一般來說 PC 只有一個,伺服器可能會有多個
  • Cores: CPU物理核心,CPU商品上宣傳的一共幾核指代的就是這個
  • Logical Processors: 邏輯處理器,如果採用超執行緒(多執行緒)技術的話,會比物理核心數多

總的來說: Logical Processors = Sockets _ Cores _ SMT(HT) Multiple
邏輯處理器數量也就代表了作業系統認為能「並行」執行的任務的最高數量

併發 VS 並行

我們對「併發」和「並行」先下個定義,「併發」指的是系統允許多個任務同時存在,「並行」則指的是系統支援多個任務同時執行,「併發」和「並行」的關鍵區別在於是否能同時執行。在只有單一邏輯處理器的情況下,我們的作業系統只能「併發」執行任務,比如早期的單核 CPU 電腦。但是我們仍然可以邊聽歌邊瀏覽網頁,這是因為 CPU 速度足夠快,可以在系統的使用過程中快速切換任務,這樣我們就感覺到多個任務同時存在在單一邏輯處理器的情況下,雖然我們可以「併發」執行任務,但實際上我們同時也只能執行一個任務,對於 IO 密集型別的任務,我們用到 CPU 的時間不多,決定任務快慢的往往是硬碟以及網路等硬體,「併發」執行也未嘗不可,但是對於計算密集型的任務,我們需要佔用更多的 CPU 時間,如果「併發」執行,則往往會造成任務的卡頓(響應時間過長),因此我們需要「並行」的執行該任務,而邏輯處理器的數量代表了能「並行」執行任務的最高數量,這也是為什麼現在的處理器大多是多核處理器的原因所在。

程序 VS 執行緒

我們使用的一個個程式可以稱為「程序」( process ),而 process 下可以開闢多個「執行緒」( thread ),這裡引用一下 Microsoft 官方對於程序和執行緒的解釋About Processes and Threads:

Each process provides the resources needed to execute a program. A process has a virtual address space, executable code, open handles to system objects, a security context, a unique process identifier, environment variables, a priority class, minimum and maximum working set sizes, and at least one thread of execution. Each process is started with a single thread, often called the primary thread, but can create additional threads from any of its threads.

A thread is the entity within a process that can be scheduled for execution. All threads of a process share its virtual address space and system resources. In addition, each thread maintains exception handlers, a scheduling priority, thread local storage, a unique thread identifier, and a set of structures the system will use to save the thread context until it is scheduled. The thread context includes the thread's set of machine registers, the kernel stack, a thread environment block, and a user stack in the address space of the thread's process. Threads can also have their own security context, which can be used for impersonating clients.

在作業系統層面,process 相互獨立,擁有一塊獨立的虛擬地址空間(記憶體中),而同一 process 下的 thread 共享該虛擬地址空間,這也是 process 和 thread 最典型,最根本的區別

多程序 VS 多執行緒

假如我們現在要開發一款瀏覽器,瀏覽器的基礎功能包括 HTTP 請求,GUI 渲染等功能,如果我們採用單執行緒來開發,那麼勢必會遇到一個問題: 當需要網路請求的時候,我們的瀏覽器就會卡住,所有的使用者操作如輸入等都沒有響應,等網路請求完成,我們才可以進行後續操作,非常影響使用者體驗,這也是為什麼像瀏覽器這樣的程式大多都是多執行緒的原因,我們需要任務同時進行。但是我們前面講到的多程序也可以多工同時進行,那麼問題就來了,當我們需要實現多工的時候,多程序和多執行緒該如何選擇呢?

多程序

前面我們提到過,程序之間是相互獨立的,每個程序有獨立的虛址空間,那麼當一個程序因為某些原因崩掉了,其他的程序也不會受到影響(主程序掛掉除外,但是主程序一般只負責排程,掛掉的機率較小),所以當我們需要較高的穩定性時,可以考慮多程序。但是建立程序的開銷是比較大的,因此要考慮資源問題。

多執行緒

多執行緒可以共享虛址空間,而且建立一個執行緒的開銷較小,這樣我們就可以減少資源的佔用。但是正是因為執行緒之間可以共享虛址空間,當一個執行緒掛掉了,整個程序會隨之掛掉,所以多執行緒的穩定性相比多程序較差。

Node.js 中的多執行緒與多程序

child_process & cluster

Node.js提供了多種方法來建立多程序,例如 child_process 提供的 child_process.spawn()child_process.fork() ,那麼什麼是 spawn :

Spawn in computing refers to a function that loads and executes a new child process. The current process may wait for the child to terminate or may continue to execute concurrent computing.

所以 child_process.spawn 的作用是建立了一個子程序,然後在子程序執行一些命令,但是 child_process.spawn() 有一個缺點,就是不能進行程序間通訊(IPC: Inter Process Communication),那麼當需要程序間通訊的時候,就需要使用child_process.fork()

涉及到現實中多程序的運用,我們往往不會只起一個子程序,當我們需要程序間共享一個埠時,這時候就可以使用Node.js提供的cluster,cluster建立子程序內部也是透過child_process.fork()實現的,支援IPC

structured clone

當我們建立了一個子程序的時候,程序間的通訊 Node.js 已經幫我們封裝好了,使用 worker.send(message)process.on('message', handle) 就可以實現程序間的通訊,以 cluster 為例:

if (cluster.isPrimary) {
  const worker = cluster.fork();
  worker.send('hi there');

} else if (cluster.isWorker) {
  process.on('message', (msg) => {
    process.send(msg);
  });
}

但是需要注意一點,我們傳送的 message 會被 structured clone 一份,然後傳遞給其他程序,因此我們需要注意如果傳遞了一個 Object 過去,Object 中定義的 Function 及其 prototype 等內容都不會被clone過去。這裡發散一下,如果我們需要深複製一個物件,而且該物件滿足Structured clone的相關演算法要求,那麼我們可以考慮使用structuredClonecaniuse)或者直接建立一個worker來複製(當然不推薦)

worker_threads

上述我們講到程序間的資源是獨立的,當我們想共享資料的時候,我們需要structured clone 對應的資料然後傳遞過去,這在共享資料量較小的時候還可以接受,但是當資料量較多時,克隆資料是一個比較大的開銷,這是我們所不能接受的,因此我們需要多執行緒來共享記憶體(資料),Node.js 中也提供了相應的方法 worker_threads

多執行緒在 ko 中的實踐

ko

ko 是基於 webpack@5.x 的打包工具,其倉庫採用了 Monorepo 的方式進行包管理。

在這裡,ko 提供了 concurrency 模式,該模式下使用多執行緒執行 eslint 、prettier 或 stylelint ,這裡簡單介紹一下如何實現。

獲取需要 lint 的所有檔案

這裡使用的是 fast-glob ,主要程式碼如下所示 factory/runner.ts

import fg, { Pattern } from 'fast-glob';

protected async getEntries(
  patterns: Pattern[],
  ignoreFiles: string[]
): Promise<string[]> {
  return fg(patterns, {
    dot: true,
    ignore: this.getIgnorePatterns(...ignoreFiles),
  });
}

private getIgnorePatterns(...ignoreFiles: string[]) {
    return ['.gitignore', ...ignoreFiles]
      .map(fileName => {
        const filePath = join(this.cwd, fileName);
        if (existsSync(filePath)) {
          return readFileSync(filePath, 'utf-8')
            .split('\n')
            .filter(str => str && !str.startsWith('#'));
        }
        return [];
      })
      .reduce((acc, current) => {
        current.forEach(p => {
          if (!acc.includes(p)) {
            acc.push(p);
          }
        });
        return acc;
      }, []);
  }

返回的是需要 lint 的所有檔案路徑

lint 相關的 Parser

我們以 eslint 為例eslint/parser.ts:

import { eslint } from 'ko-lint-config';
import LintParserFactory from '../factory/parser';
import { IParserOpts } from '../interfaces';

class ESLintParser extends LintParserFactory {
  static readonly EXTENSIONS = ['ts', 'tsx', 'js', 'jsx'];
  private eslintInstance: eslint.ESLint;
  private opts: IParserOpts;
  private config: Record<string, any>;

  constructor(opts: IParserOpts) {
    super();
    this.opts = opts;
    this.generateConfig();
    this.initInstance();
  }

  private initInstance() {
    const { write } = this.opts;
    this.eslintInstance = new eslint.ESLint({
      fix: write,
      overrideConfig: this.config,
      useEslintrc: false,
      extensions: ESLintParser.EXTENSIONS,
    });
  }

  public async format(file: string): Promise<string> {
    const formatter = await this.eslintInstance.loadFormatter();
    let resultText = '';
    try {
      const result = await this.eslintInstance.lintFiles(file);
      if (result[0].errorCount) {
        resultText = formatter.format(result) as string;
      }
      return resultText;
    } catch (ex) {
      console.log(ex);
      process.exit(1);
    }
  }

  public generateConfig() {
    if (this.opts.configPath) {
      this.config = this.getConfigFromFile(this.opts.configPath);
    } else {
      const localConfigPath = this.detectLocalRunnerConfig(this.opts.name);
      if (localConfigPath) {
        this.config = this.getConfigFromFile(localConfigPath);
      }
    }
  }
}

export default ESLintParser;

所有的 parser 實現了 format() 方法,作用是輸入一個檔案的路徑,然後進行 lint ,如果有相關的錯誤則返回錯誤結果。

Thread Pool

建立一個執行緒的是有開銷的,雖然相比建立程序而言消耗的較小,但是我們也並不能無休止建立執行緒。執行緒是需要排程的,如果我們建立了很多執行緒,那麼系統花線上程排程的時間往往會更長,導致的結果是我們開了多個執行緒,但是執行程式的耗時反而更長了。為了更好的使用執行緒,我們引入執行緒池的概念 WikiPedia

In computer programming, a thread pool is a software design pattern for achieving concurrency of execution in a computer program. Often also called a replicated workers or worker-crew model, a thread pool maintains multiple threads waiting for tasks to be allocated for concurrent execution by the supervising program

還是WikiPedia的示例圖:
file

簡單來說,執行緒池建立了一定數量的執行緒,每個執行緒從任務佇列中獲取任務並執行,然後繼續執行下一個任務直到結束。ko中也實現了相關的執行緒池 threads/Pool.ts

import { join } from 'path';
import { Worker } from 'worker_threads';
import { IThreadOpts, IParserOpts } from '../interfaces';

class ThreadPool {
  private readonly workers: Worker[] = [];
  private readonly workerPList: Promise<boolean>[] = [];
  private readonly opts: IThreadOpts;
  private queue: string[];
  private stdout: string[] = [];

  constructor(opts: IThreadOpts) {
    console.log('Using Multithreading...');
    this.opts = opts;
    this.queue = this.opts.entries;
    this.format();
  }

  format() {
    const { concurrentNumber, configPath, write, name } = this.opts;
    if (this.workers.length < concurrentNumber) {
      this.workerPList.push(
        this.createWorker({
          configPath,
          write,
          name,
        })
      );
      this.format();
    }
  }

  createWorker(opts: IParserOpts): Promise<boolean> {
    const worker = new Worker(join(__dirname, './Worker.js'), {
      workerData: {
        opts,
      },
    });
    return new Promise(resolve => {
      worker.postMessage(this.queue.shift());
      worker.on('message', (result: string) => {
        this.stdout.push(result);
        if (this.queue.length === 0) {
          resolve(true);
        } else {
          const next = this.queue.shift();
          worker.postMessage(next);
        }
      });
      worker.on('error', err => {
        console.log(err);
        process.exit(1);
      });
      this.workers.push(worker);
    });
  }

  async exec(): Promise<string[]> {
    return Promise.all(this.workerPList).then(() => {
      return this.stdout;
    });
  }
}

export default ThreadPool;

這裡的 workers 維護了多個 worker ,相當於執行緒池的概念,而任務佇列對應的則是 queue ,也就是傳入的需要 lint 的所有檔案,當一個 worker 執行完一個檔案的 lint 之後,從 queue 中拿一個新的檔案繼續執行新的 lint 任務,當 queue 為空時,我們結束任務並返回最終結果。

需要注意的一點是關於 concurrentNumber 也就是我們啟動的執行緒數量,這裡我們預設是 Logical Processors 的數量。

結果

那麼我們來對比一下多執行緒和普通情況下的效能,以執行 eslint 為例:

硬體資訊:

  • CPU: Apple M1
  • Memory: 8 GB LPDDR4

普通模式下的log為:

exec cmd: pnpm exec ko eslint '**/*.{ts,tsx,js,jsx}' --write
exec eslint with 704 files cost 31.71s

多執行緒模式下的log為:

exec cmd: pnpm exec ko eslint '**/*.{ts,tsx,js,jsx}' --write --concurrency
Using Multithreading...
exec eslint with 704 files cost 23.60s

可以看到效能有一定程度的提升,但是並沒有我們想象中的效能提升多倍,這是為什麼呢?我們簡單分析一下:

  • 執行緒啟動及其排程消耗了一定的時間
  • 執行緒內部涉及到了IO操作,而不是單純的運算

但是可以肯定的是,隨著需要 lint 的檔案數量增多,兩個模式下所用的時間差會增大。

執行緒安全

在 ko 中, 我們針對 lint 進行了多執行緒的操作,效能上有了一定程度的提升,但是我們執行緒間總的來說是相互獨立的,沒有使用到共享記憶體的情況。那麼當我們需要共享記憶體時,會遇到一個問題,我們啟用了多個執行緒,執行緒之間針對共享記憶體可能存在競爭關係,也就是可能會同時操作共享記憶體中的資料,這個時候我們就不能保證資料的準確性,專業術語描述為不是執行緒安全的。遇到這種情況,我們一般會涉及到一個專業術語(Lock)

我們回到 work_threads ,看一下官方文件中是如何共享記憶體的:

const { MessageChannel } = require('worker_threads');
const { port1, port2 } = new MessageChannel();

port1.on('message', (message) => console.log(message));

const uint8Array = new Uint8Array([ 1, 2, 3, 4 ]);
// This posts a copy of `uint8Array`:
port2.postMessage(uint8Array);
// This does not copy data, but renders `uint8Array` unusable:
port2.postMessage(uint8Array, [ uint8Array.buffer ]);

// The memory for the `sharedUint8Array` is accessible from both the
// original and the copy received by `.on('message')`:
const sharedUint8Array = new Uint8Array(new SharedArrayBuffer(4));
port2.postMessage(sharedUint8Array);

// This transfers a freshly created message port to the receiver.
// This can be used, for example, to create communication channels between
// multiple `Worker` threads that are children of the same parent thread.
const otherChannel = new MessageChannel();
port2.postMessage({ port: otherChannel.port1 }, [ otherChannel.port1 ]);

注意一點,如果我們想共享記憶體,我們可以傳遞 ArrayBuffer 或者 SharedArrayBuffer ,那麼這兩種型別的資料有什麼特殊性呢?

答案是 ArrayBufferSharedArrayBuffer 支援 Atomics 一起使用,可以實現 Lock 相關的概念

最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

  • 大資料分散式任務排程系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大資料領域的 SQL Parser 專案——dt-sql-parser
  • 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
  • 一個針對 antd 的元件測試工具庫——ant-design-testing

相關文章