從壓縮到密碼學,從網路到您正在閱讀此文的網頁瀏覽器,世界都仰賴 C 語言運作。如果程式不是用 C 寫成的,它也會遵循 C ABI(C++、Rust、Zig 等),並以 C 語言函式庫的形式提供。C 語言和 C ABI 是系統程式設計的過去、現在和未來。
這就是為什麼在 Bun v1.1.28 中,我們引入了從 JavaScript 編譯和執行原生 C 語言的實驗性支援
#include <stdio.h>
void hello() {
printf("You can now compile & run C in Bun!\n");
}
import { cc } from "bun:ffi";
export const {
symbols: { hello },
} = cc({
source: "./hello.c",
symbols: {
hello: {
returns: "void",
args: [],
},
},
});
hello();
在 Twitter 上,許多人問了相同的問題
「我為什麼要從 JavaScript 編譯和執行 C 程式?」
先前,您有兩種從 JavaScript 使用系統函式庫的選項
- 編寫 N-API (napi) 附加元件或 V8 C++ API 函式庫附加元件
- 透過 emscripten 或 wasm-pack 編譯為 WASM/WASI
N-API (napi) 有什麼問題?
N-API (napi) 是一個與執行階段無關的 C API,用於將原生函式庫公開給 JavaScript。Bun 和 Node.js 都有實作它。在 napi 之前,原生附加元件大多使用 V8 C++ API,這表示每次 Node.js 更新 V8 時都可能發生重大變更。
編譯原生附加元件會破壞 CI
原生附加元件通常依賴 "postinstall"
指令碼,以使用 node-gyp
編譯 N-API 附加元件。node-gyp
依賴 Python 3 和最新的 C++ 編譯器。
對於許多人來說,需要在 CI 中安裝 Python 3 和 C++ 編譯器來建置前端 JavaScript 應用程式,是一個令人不快的意外。
編譯原生附加元件對維護者來說很複雜
為了處理這個問題,有些函式庫會預先建置它們的套件,利用 package.json 中的 "os"
和 "cpu"
欄位。將複雜性從使用者轉移到維護者對生態系統有益,但維護 10 個不同建置目標的建置矩陣並不容易。
"optionalDependencies": {
"@napi-rs/canvas-win32-x64-msvc": "0.1.55",
"@napi-rs/canvas-darwin-x64": "0.1.55",
"@napi-rs/canvas-linux-x64-gnu": "0.1.55",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.55",
"@napi-rs/canvas-linux-x64-musl": "0.1.55",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.55",
"@napi-rs/canvas-linux-arm64-musl": "0.1.55",
"@napi-rs/canvas-darwin-arm64": "0.1.55",
"@napi-rs/canvas-android-arm64": "0.1.55"
}
JavaScript → N-API 函式呼叫:3 倍的額外負擔
為了換取更複雜的建置,我們得到了什麼?
JavaScript → 原生呼叫額外負擔 | 機制 |
---|---|
7ns - 15ns | N-API |
2ns | JavaScriptCore C++ API(下限) |
使用 JavaScriptCore C++ API,一個簡單的 noop 函式每次呼叫的成本為 2ns。使用 N-API,noop 函式每次呼叫的成本為 7ns。
我們為什麼要為此付出 3 倍的效能損失?
不幸的是,這是 napi 中的 API 設計問題。為了使 napi 與執行階段無關,從 JavaScript 值讀取整數等簡單操作涉及動態函式庫呼叫。為了使 napi 與語言無關,引數的執行階段類型檢查發生在每個動態函式庫呼叫中。更複雜的操作涉及許多記憶體配置(或 GC 的物件配置)和多層指標間接引用。N-API 從未被設計為快速。
JavaScript 是世界上最流行的程式設計語言。我們可以做得更好嗎?
WebAssembly 呢?
為了規避 N-API 建置的複雜性和效能問題,有些專案選擇將其原生附加元件編譯為 WebAssembly,並在 JavaScript 中匯入它。
由於 JavaScript 引擎可以內聯跨越 WebAssembly <> JavaScript 邊界的函式呼叫,因此這可以奏效。
但是,對於系統函式庫來說,WebAssembly 的隔離記憶體模型帶來了嚴重的權衡。
隔離表示沒有系統呼叫
WebAssembly 只能存取執行階段公開給它的函式。通常,那是 JavaScript。
那麼,依賴系統 API 的函式庫呢?例如 macOS Keychain API(用於安全地儲存/擷取密碼)或 錄音?如果您的 CLI 想要使用 Windows 登錄檔呢?
隔離表示複製所有內容
現代處理器支援約 280 TB 的可定址記憶體(48 位元)。WebAssembly 是 32 位元,只能存取自己的記憶體。
這表示預設情況下,傳遞字串和二進位資料 JavaScript <=> WebAssembly 每次都必須複製。對於許多專案來說,這抵消了利用 WebAssembly 帶來的任何效能提升。
如果 N-API 和 WebAssembly 不是伺服器端 JavaScript 的唯一選項呢?如果我們可以從 JavaScript 編譯和執行原生 C 語言,並具有共享記憶體和近乎零的呼叫額外負擔呢?
從 JavaScript 編譯並執行原生 C
以下是一個快速範例,它在 C 語言中編譯一個隨機數字產生器,並在 JavaScript 中執行它。
#include <stdio.h>
#include <stdlib.h>
int myRandom() {
return rand() + 42;
}
編譯並執行 C 語言的 JavaScript 程式碼
import { cc } from "bun:ffi";
export const {
symbols: { myRandom },
} = cc({
source: "./myRandom.c",
symbols: {
myRandom: {
returns: "int",
args: [],
},
},
});
console.log("myRandom() =", myRandom());
最後,輸出結果
bun ./main.js
myRandom() = 43
這是如何運作的?
bun:ffi
使用 TinyCC 在記憶體中編譯、連結和重新定位 C 程式。從那裡,它產生內聯函式包裝器,將 JavaScript 原始類型 <=> C 原始類型轉換。
例如,為了將 C 語言中的 int
轉換為 JavaScriptCore 的 EncodedJSValue 表示法,程式碼基本上會執行以下操作
static int64_t int32_to_js(int32_t input) {
return 0xfffe000000000000ll | (uint32_t)input;
}
與 N-API 不同,這些類型轉換會自動發生,且沒有動態調度額外負擔。由於這些包裝器是在 C 編譯時產生的,因此我們可以安全地內聯類型轉換,而無需擔心相容性問題,也不會犧牲效能。
bun:ffi
編譯速度很快
如果您之前使用過 clang
或 gcc
,您可能會想
clang/gcc 使用者:「太好了 🙄 現在我每次執行這個 JS 都必須等待 10 秒來編譯 C 語言。」
讓我們測量一下使用 bun:ffi
編譯需要多長時間
import { cc } from "bun:ffi";
console.time("Compile ./myRandom.c");
export const {
symbols: { myRandom },
} = cc({
source: "./myRandom.c",
symbols: {
myRandom: {
returns: "int",
args: [],
},
},
});
console.timeEnd("Compile ./myRandom.c");
以及輸出結果
bun ./main.js
[5.16ms] Compile ./myRandom.c
myRandom() = 43
那是 5.16 毫秒。感謝 TinyCC,在 Bun 中編譯 C 語言速度很快。如果編譯需要 10 秒,我們就不會安心地發布它。
bun:ffi
具有低額外負擔
外部函式介面 (FFI) 因速度慢而聞名。但在 Bun 中,情況有所不同。
在我們在 Bun 中測量它之前,讓我們了解一下它可以達到的最快速度上限。為了簡單起見,讓我們使用 Google 的基準測試函式庫(需要 .cpp 檔案)
#include <stdio.h>
#include <stdlib.h>
#include <benchmark/benchmark.h>
int myRandom() {
return rand() + 42;
}
static void BM_MyRandom(benchmark::State& state) {
for (auto _ : state) {
benchmark::DoNotOptimize(myRandom());
}
}
BENCHMARK(BM_MyRandom);
BENCHMARK_MAIN();
以及輸出結果
clang++ ./bench.cpp -L/opt/homebrew/lib -l benchmark -O3 -I/opt/homebrew/include -o bench
./bench
------------------------------------------------------
Benchmark Time CPU Iterations
------------------------------------------------------
BM_MyRandom 4.67 ns 4.66 ns 150144353
因此,在 C/C++ 中,每次呼叫為 4 奈秒。這表示它可能達到的最快速度上限。
使用 bun:ffi
需要多長時間?
import { bench, run } from 'mitata';
import { myRandom } from './main';
bench('myRandom', () => {
myRandom();
});
run();
在我的機器上,結果是
bun ./bench.js
cpu: Apple M3 Max
runtime: bun 1.1.28 (arm64-darwin)
benchmark time (avg) (min … max) p75 p99 p999
------------------------------------------------- -----------------------------
myRandom 6.26 ns/iter (6.16 ns … 17.68 ns) 6.23 ns 7.67 ns 10.17 ns
6 奈秒。因此,bun:ffi
的每次呼叫額外負擔僅為 6ns - 4ns = 2ns。
您可以使用這個建置什麼?
bun:ffi 可以使用動態連結的共享函式庫。
使用 ffmpeg 將短片轉換速度提高 3 倍
透過避免產生新程序和為每個影片分配大量記憶體的額外負擔,您可以將短片轉換速度提高 3 倍。
import { cc, ptr } from "bun:ffi";
import source from "./mp4.c" with {type: 'file'};
import { basename, extname, join } from "path";
console.time(`Compile ./mp4.c`);
const {
symbols: { convert_file_to_mp4 },
} = cc({
source,
library: ["c", "avcodec", "swscale", "avformat"],
symbols: {
convert_file_to_mp4: {
returns: "int",
args: ["cstring", "cstring"],
},
},
});
console.timeEnd(`Compile ./mp4.c`);
const outname = join(
process.cwd(),
basename(process.argv.at(2), extname(process.argv.at(2))) + ".mp4"
);
const input = Buffer.from(process.argv.at(2) + "\0");
const output = Buffer.from(outname + "\0");
for (let i = 0; i < 10; i++) {
console.time(`Convert ${process.argv.at(2)} to ${outname}`);
const result = convert_file_to_mp4(ptr(input), ptr(output));
if (result == 0) {
console.timeEnd(`Convert ${process.argv.at(2)} to ${outname}`);
}
}
#include <dlfcn.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/imgutils.h>
#include <libavutil/opt.h>
#include <libswscale/swscale.h>
#include <stdio.h>
#include <stdlib.h>
int to_mp4(void *buf, size_t buflen, void **out, size_t *outlen) {
AVFormatContext *input_ctx = NULL, *output_ctx = NULL;
AVIOContext *input_io_ctx = NULL, *output_io_ctx = NULL;
uint8_t *output_buffer = NULL;
int ret = 0;
int64_t *last_dts = NULL;
// Register all codecs and formats
// Create input IO context
input_io_ctx = avio_alloc_context(buf, buflen, 0, NULL, NULL, NULL, NULL);
if (!input_io_ctx) {
ret = AVERROR(ENOMEM);
goto end;
}
// Allocate input format context
input_ctx = avformat_alloc_context();
if (!input_ctx) {
ret = AVERROR(ENOMEM);
goto end;
}
input_ctx->pb = input_io_ctx;
// Open input
if ((ret = avformat_open_input(&input_ctx, NULL, NULL, NULL)) < 0) {
goto end;
}
// Retrieve stream information
if ((ret = avformat_find_stream_info(input_ctx, NULL)) < 0) {
goto end;
}
// Allocate output format context
avformat_alloc_output_context2(&output_ctx, NULL, "mp4", NULL);
if (!output_ctx) {
ret = AVERROR(ENOMEM);
goto end;
}
// Create output IO context
ret = avio_open_dyn_buf(&output_ctx->pb);
if (ret < 0) {
goto end;
}
// Copy streams
for (int i = 0; i < input_ctx->nb_streams; i++) {
AVStream *in_stream = input_ctx->streams[i];
AVStream *out_stream = avformat_new_stream(output_ctx, NULL);
if (!out_stream) {
ret = AVERROR(ENOMEM);
goto end;
}
ret = avcodec_parameters_copy(out_stream->codecpar, in_stream->codecpar);
if (ret < 0) {
goto end;
}
out_stream->codecpar->codec_tag = 0;
}
// Write header
ret = avformat_write_header(output_ctx, NULL);
if (ret < 0) {
goto end;
}
// Allocate last_dts array
last_dts = calloc(input_ctx->nb_streams, sizeof(int64_t));
if (!last_dts) {
ret = AVERROR(ENOMEM);
goto end;
}
// Copy packets
AVPacket pkt;
while (1) {
ret = av_read_frame(input_ctx, &pkt);
if (ret < 0) {
break;
}
AVStream *in_stream = input_ctx->streams[pkt.stream_index];
AVStream *out_stream = output_ctx->streams[pkt.stream_index];
// Convert timestamps
pkt.pts =
av_rescale_q_rnd(pkt.pts, in_stream->time_base, out_stream->time_base,
AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
pkt.dts =
av_rescale_q_rnd(pkt.dts, in_stream->time_base, out_stream->time_base,
AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX);
pkt.duration =
av_rescale_q(pkt.duration, in_stream->time_base, out_stream->time_base);
// Ensure monotonically increasing DTS
if (pkt.dts <= last_dts[pkt.stream_index]) {
pkt.dts = last_dts[pkt.stream_index] + 1;
pkt.pts = FFMAX(pkt.pts, pkt.dts);
}
last_dts[pkt.stream_index] = pkt.dts;
pkt.pos = -1;
ret = av_interleaved_write_frame(output_ctx, &pkt);
if (ret < 0) {
char errbuf[AV_ERROR_MAX_STRING_SIZE];
av_strerror(ret, errbuf, AV_ERROR_MAX_STRING_SIZE);
fprintf(stderr, "Error writing frame: %s\n", errbuf);
break;
}
av_packet_unref(&pkt);
}
// Write trailer
ret = av_write_trailer(output_ctx);
if (ret < 0) {
goto end;
}
// Get the output buffer
*outlen = avio_close_dyn_buf(output_ctx->pb, &output_buffer);
*out = output_buffer;
output_ctx->pb = NULL; // Set to NULL to prevent double free
ret = 0; // Success
end:
if (input_ctx) {
avformat_close_input(&input_ctx);
}
if (output_ctx) {
avformat_free_context(output_ctx);
}
if (input_io_ctx) {
av_freep(&input_io_ctx->buffer);
av_freep(&input_io_ctx);
}
return ret;
}
int convert_file_to_mp4(const char *input_filename,
const char *output_filename) {
FILE *input_file = NULL;
FILE *output_file = NULL;
uint8_t *input_buffer = NULL;
uint8_t *output_buffer = NULL;
size_t input_size = 0;
size_t output_size = 0;
int ret = 0;
// Open the input file
input_file = fopen(input_filename, "rb");
if (!input_file) {
perror("Could not open input file");
return -1;
}
// Get the size of the input file
fseek(input_file, 0, SEEK_END);
input_size = ftell(input_file);
fseek(input_file, 0, SEEK_SET);
// Allocate memory for the input buffer
input_buffer = (uint8_t *)malloc(input_size);
if (!input_buffer) {
perror("Could not allocate input buffer");
ret = -1;
goto cleanup;
}
// Read the input file into the buffer
if (fread(input_buffer, 1, input_size, input_file) != input_size) {
perror("Could not read input file");
ret = -1;
goto cleanup;
}
// Call the to_mp4 function to convert the buffer
ret = to_mp4(input_buffer, input_size, (void **)&output_buffer, &output_size);
if (ret < 0) {
fprintf(stderr, "Error converting to MP4\n");
goto cleanup;
}
// Open the output file
output_file = fopen(output_filename, "wb");
if (!output_file) {
perror("Could not open output file");
ret = -1;
goto cleanup;
}
// Write the output buffer to the file
if (fwrite(output_buffer, 1, output_size, output_file) != output_size) {
perror("Could not write output file");
ret = -1;
goto cleanup;
}
cleanup:
if (output_buffer) {
av_free(output_buffer);
}
if (input_file) {
fclose(input_file);
}
if (output_file) {
fclose(output_file);
}
return ret;
}
// for running it standalone
int main(const int argc, const char **argv) {
if (argc != 3) {
printf("Usage: %s <input_file> <output_file>\n", argv[0]);
return -1;
}
const char *input_filename = argv[1];
const char *output_filename = argv[2];
int result = convert_file_to_mp4(input_filename, output_filename);
if (result == 0) {
printf("Conversion successful!\n");
} else {
printf("Conversion failed!\n");
}
return result;
}
使用 macOS Keychain API 安全地儲存和載入密碼
macOS 具有內建的 Keychain API,用於安全地儲存和擷取密碼,但這並未公開給 JavaScript。如果您可以直接在您的 JS 專案中編寫幾行 C 程式碼並完成它,而不是弄清楚如何使用 N-API 包裝它、使用 node-gyp 設定 CMake,那會怎麼樣?
import { cc, ptr, CString } from "bun:ffi";
const {
symbols: { setPassword, getPassword, deletePassword },
} = cc({
source: "./keychain.c",
flags: [
"-framework",
"Security",
"-framework",
"CoreFoundation",
"-framework",
"Foundation",
],
symbols: {
setPassword: {
args: ["cstring", "cstring", "cstring"],
returns: "i32",
},
getPassword: {
args: ["cstring", "cstring", "ptr", "ptr"],
returns: "i32",
},
deletePassword: {
args: ["cstring", "cstring"],
returns: "i32",
},
},
});
var service = Buffer.from("com.bun.test.keychain\0");
var account = Buffer.from("bun\0");
var password = Buffer.alloc(1024);
password.write("password\0");
var passwordPtr = new BigUint64Array(1);
passwordPtr[0] = BigInt(ptr(password));
var passwordLength = new Uint32Array(1);
setPassword(ptr(service), ptr(account), ptr(password));
passwordLength[0] = 1024;
password.fill(0);
getPassword(ptr(service), ptr(account), ptr(passwordPtr), ptr(passwordLength));
const result = new CString(
Number(passwordPtr[0]),
0,
passwordLength[0]
);
console.log(result);
#include <Security/Security.h>
#include <stdio.h>
#include <string.h>
// Function to set a password in the keychain
OSStatus setPassword(const char* service, const char* account, const char* password) {
SecKeychainItemRef item = NULL;
OSStatus status = SecKeychainFindGenericPassword(
NULL,
strlen(service), service,
strlen(account), account,
NULL, NULL,
&item
);
if (status == errSecSuccess) {
// Update existing item
status = SecKeychainItemModifyAttributesAndData(
item,
NULL,
strlen(password),
password
);
CFRelease(item);
} else if (status == errSecItemNotFound) {
// Add new item
status = SecKeychainAddGenericPassword(
NULL,
strlen(service), service,
strlen(account), account,
strlen(password), password,
NULL
);
}
return status;
}
// Function to get a password from the keychain
OSStatus getPassword(const char* service, const char* account, char** password, UInt32* passwordLength) {
return SecKeychainFindGenericPassword(
NULL,
strlen(service), service,
strlen(account), account,
passwordLength, (void**)password,
NULL
);
}
// Function to delete a password from the keychain
OSStatus deletePassword(const char* service, const char* account) {
SecKeychainItemRef item = NULL;
OSStatus status = SecKeychainFindGenericPassword(
NULL,
strlen(service), service,
strlen(account), account,
NULL, NULL,
&item
);
if (status == errSecSuccess) {
status = SecKeychainItemDelete(item);
CFRelease(item);
}
return status;
}
這有什麼好處?
這是一種低樣板的方式,可從 JavaScript 使用 C 語言函式庫和系統函式庫。執行 JavaScript 的同一個專案也可以執行 C 語言,而無需單獨的建置步驟。
它適用於將 C 或類似 C 語言的函式庫繫結到 JavaScript 的膠水程式碼。有時,您想要從 JavaScript 使用 C 語言函式庫或系統 API,但該函式庫從未打算從 JavaScript 使用。
編寫一些 C 程式碼來將這類程式碼包裝成 JavaScript 友善的 API 通常是最簡單的方法,因為
- 範例是以 C 語言編寫的,而不是透過 FFI 在 JavaScript 中編寫的。
- 使用 FFI 表示您必須在 JavaScript 和 C 之間進行心智翻譯。在 C 語言中使用指標比透過 JavaScript 中的類型化陣列在 FFI 中使用指標更容易。那麼,為什麼不讓自己更輕鬆呢?
這不適用於什麼?
每個工具都有其權衡取捨。
- 您可能不想使用它來編譯大型 C 專案,例如 PostgresSQL 或 SQLite。TinyCC 編譯成效能尚可的 C 語言,但它不會執行 Clang 或 GCC 所做的進階最佳化,例如自動向量化或非常專業的 CPU 指令。
- 您可能不會透過 C 語言微調程式碼庫的小部分來獲得太多效能提升,但很樂意被證明是錯的!