聊聊No.js---基于V8和io_uring的JS運行時
前言:閱讀Node.js的源碼已經(jīng)有一段時間了,最近也看了一下新的JS運行時Just的一些實現(xiàn),就產(chǎn)生了自己寫一個JS運行時的想法,雖然幾個月前就基于V8寫了一個簡單的JS運行時,但功能比較簡單,這次廢棄了之前的代碼,重新寫了一遍,寫這個JS運行時的目的最主要是為了學習,事實也證明,寫一個JS運行時的確可以學到很多東西。本文介紹運行時No.js的一些設計和實現(xiàn),取名No.js一來是受Node.js的影響,二來是為了說明不僅僅是JS,也就是利用V8拓展了JS的功能,同時,前端開發(fā)者要學習的知識也不僅僅是JS了。
1 為什么選io_uring
io_uring是Linux下新一代的高性能異步IO框架,也是No.js的核心。在No.js中,io_uring用于實現(xiàn)事件循環(huán)。為什么不選用epoll呢?因為epoll不支持文件IO,如果選用epoll,還需要自己實現(xiàn)一個線程池,還需要實現(xiàn)線程和主線程的通信,以及線程池任務和事件循環(huán)的融合,No.js希望把事件變得純粹,簡單。而io_uring是支持異步文件IO的,并且io_uring是真正的異步IO框架,支持的功能也非常豐富,比如在epoll里我們監(jiān)聽一個socket后,需要把socket fd注冊到epoll中,等待有連接時執(zhí)行回調(diào),然后調(diào)用accept獲取新的fd,而io_uring直接就幫我們獲取新的fd,io_uring通知我們的時候,我們就已經(jīng)拿到新的fd了,epoll時代,epoll通知我們可以做什么事情了,然后我們自己去做,io_uring時代,io_uring通知我們什么事情完成了。
2 No.js框架的設計
No.js目前的實現(xiàn)比較清晰簡單,所有的功能都通過c和c++實現(xiàn),然后通過V8暴露給JS實現(xiàn)。No.cc是初始化的入口,core目錄是所有功能實現(xiàn)的地方,core下面按照模塊功能劃分。下面我們看看整體的框架實現(xiàn)。
- int main(int argc, char* argv[]) {
- // ...
- Isolate* isolate = Isolate::New(create_params);
- {
- Isolate::Scope isolate_scope(isolate);
- HandleScope handle_scope(isolate);
- // 創(chuàng)建全局對象
- Local<ObjectTemplate> global = ObjectTemplate::New(isolate);
- // 創(chuàng)建執(zhí)行上下文
- Local<Context> context = Context::New(isolate, nullptr, global);
- Environment * env = new Environment(context);
- Context::Scope context_scope(context);
- // 創(chuàng)建No,核心對象
- Local<Object> No = Object::New(isolate);
- // 注冊c、c++模塊
- register_builtins(isolate, No);
- // 獲取全局對象
- Local<Object> globalInstance = context->Global();
- // 設置全局屬性
- globalInstance->Set(context, String::NewFromUtf8Literal(isolate, "No",
- NewStringType::kNormal), No);
- // 設置全局屬性global指向全局對象
- globalInstance->Set(context, String::NewFromUtf8Literal(isolate,
- "global",
- NewStringType::kNormal), globalInstance).Check();
- {
- // 打開文件
- int fd = open(argv[1], O_RDONLY);
- struct stat info;
- // 取得文件信息
- fstat(fd, &info);
- // 分配內(nèi)存保存文件內(nèi)容
- char *ptr = (char *)malloc(info.st_size + 1);
- read(fd, (void *)ptr, info.st_size);
- // 要執(zhí)行的js代碼
- Local<String> source = String::NewFromUtf8(isolate, ptr,
- NewStringType::kNormal,
- info.st_size).ToLocalChecked();
- // 編譯
- Local<Script> script = Script::Compile(context, source).ToLocalChecked();
- // 解析完應該沒用了,釋放內(nèi)存
- free(ptr);
- // 執(zhí)行
- Local<Value> result = script->Run(context).ToLocalChecked();
- // 進入事件循環(huán)
- Run(env->GetIOUringData());
- }
- }
- return 0;
- }
大部分邏輯都是V8初始化的標準流程,添加的內(nèi)容主要包括注冊c、c++模塊、掛載No到全局作用域、開啟事件循環(huán)。
2.1 注冊模塊
No在初始化的時候會把所有C++模塊注冊到No中,因為No是全局屬性,所以在JS里可以直接訪問C++模塊,不需要require。我們看看register_builtins。
- void No::Core::register_builtins(Isolate * isolate, Local<Object> target) {
- FS::Init(isolate, target);
- TCP::Init(isolate, target);
- Process::Init(isolate, target);
- Console::Init(isolate, target);
- IO::Init(isolate, target);
- Net::Init(isolate, target);
- UDP::Init(isolate, target);
- UNIX_DOMAIN::Init(isolate, target);
- Signal::Init(isolate, target);
- Timer::Init(isolate, target);
- }
register_builtins會調(diào)用各個模塊的Init函數(shù),各個模塊自己實現(xiàn)需要掛載的功能,從代碼中可以看到目前實現(xiàn)的功能。我們隨便找一個模塊看看初始化的邏輯。
- void No::FS::Init(Isolate* isolate, Local<Object> target) {
- Local<ObjectTemplate> fs = ObjectTemplate::New(isolate);
- setMethod(isolate, fs, "open", No::FS::Open);
- setMethod(isolate, fs, "openat", No::FS::OpenAt);
- setMethod(isolate, fs, "close", No::IO::Close);
- setMethod(isolate, fs, "read", No::IO::Read);
- setMethod(isolate, fs, "write", No::IO::Write);
- setMethod(isolate, fs, "readv", No::IO::ReadV);
- setMethod(isolate, fs, "writev", No::IO::WriteV);
- setObjectValue(isolate, target, "fs", fs->NewInstance(isolate->GetCurrentContext()).ToLocalChecked());
- }
掛載的邏輯就是新建一個對象,然后設置對象的屬性,最后把這個對象作為No對象的一個屬性掛載到No中,最后形成如下一個結(jié)構。
- var No = {
- fs: {},
- tcp: {}
- }
這就完成了所有核心模塊的注冊。
2.2 執(zhí)行JS
注冊完核心模塊后就是執(zhí)行業(yè)務JS。我們隨便看個例子。
- const {
- fs,
- console
- } = No;const fd = fs.open('./test/file/1.txt');const arr = new ArrayBuffer(100);
- fs.readv(fd,arr , 0, (res) => {console.log(res)});
- console.log(new Uint8Array(arr));
以上是讀取一個文件的例子,從中也可以看到No的使用方式。No沒有實現(xiàn)類似Node.js的Buffer,是直接使用V8的ArrayBuffer的,ArrayBuffer使用的是V8堆外內(nèi)存,readv是C++層實現(xiàn)的函數(shù),我們一會單獨介紹。
2.3 開啟事件循環(huán)
執(zhí)行完JS后,最后進入事件循環(huán)。
- void No::io_uring::RunIOUring(struct io_uring_info *io_uring_data) {
- struct io_uring* ring = &io_uring_data->ring;
- struct io_uring_cqe* cqe;
- struct request* req;
- while(io_uring_data->stop != 1 && io_uring_data->pending != 0) {
- // 提交請求給內(nèi)核
- int count = io_uring_submit_and_wait(ring, 1);
- // 處理每一個完成的請求
- while (1) {
- io_uring_peek_cqe(ring, &cqe);
- if (cqe == NULL)
- break;
- --io_uring_data->pending;
- // 拿到請求上下文
- req = (struct request*) (uintptr_t) cqe->user_data;
- req->res = cqe->res;
- io_uring_cq_advance(ring, 1);
- // 執(zhí)行回調(diào)
- if (req->cb != nullptr) {
- req->cb((void *)req);
- }
- }
- }
- }
從事件循環(huán)的代碼中大致可以看到原理,首先判斷事件循環(huán)是不是停止或者可以停止了,如果還沒有停止,則等待任務完成,然后取出任務執(zhí)行任務的對象。
3 任務的封裝和處理
io_uring的任務是以結(jié)構體io_uring_sqe表示的,但是io_uring_sqe只是記錄了和io_uring框架本身相關的一些數(shù)據(jù)結(jié)構,因為是異步的模式,所以在任務完成的時候,我們需要知道,這個任務關聯(lián)的上下文和回調(diào)。io_uring_sqe提供了user_data字段用于保存請求對應的上下文。流程如下。設置和提交請求
- // 獲取一個io_uring的請求結(jié)構體
- struct io_uring_sqe *sqe = io_uring_get_sqe(&io_uring_data->ring);
- // 自定義結(jié)構體
- struct io_request * file_req = (struct io_request *)req;
- // 設置請求的字段
- io_uring_prep_read(sqe, file_req->fd, file_req->buf, file_req->len, file_req->offset);
- // 保存請求上下文,響應的時候用
- io_uring_sqe_set_data(sqe, (void *)req);
- // 提交請求
- io_uring_submit(&io_uring_data->ring);
我們看到提交請求的時候,設置了請求上下文是我們自定義的結(jié)構體,具體結(jié)構體類型根據(jù)操作類型而不同。我們看看請求完成時是如何處理的。
- struct io_uring_cqe* cqe;io_uring_peek_cqe(ring, &cqe);// 拿到請求上下文
- req = (struct request*) (uintptr_t) cqe->user_data;// 記錄請求結(jié)果
- req->res = cqe->res;
- req->cb((void *)req);
以上就是一個No請求和響應的處理過程。No為不同的操作類型封裝了不同的結(jié)構體。首先封裝了一個請求的基類。
- #define REQUEST \
- int op; \
- // io_uring執(zhí)行的回調(diào)
- request_cb cb; \
- // io_uring請求的結(jié)果
- int res;\
- // 業(yè)務上下文
- void * data; \
- int flag;
類似io_uring通過user_data字段關聯(lián)請求響應上下文。REQUEST 里通過data關聯(lián)請求和響應上下文,通過user_data字段,我們在任務完成時可以執(zhí)行應該執(zhí)行哪個回調(diào)以及對應的上下文。但是執(zhí)行某個回調(diào)時,該回調(diào)函數(shù)需要的上下文可能不僅僅是io_uring返回的結(jié)果,這時候就可以使用data字段記錄額外的上下文。一會會具體介紹?;赗EQUEST,針對不同的操作封裝了不同的結(jié)構體,比如文件請求。
- struct io_request {
- REQUEST
- int fd;
- int offset;
- void *buf;
- int len;
- };
下面我們分析一個具體請求的過程,這里以read為例。
- void read_write_request(V8_ARGS, int op) {
- V8_ISOLATE
- int fd = args[0].As<Uint32>()->Value();
- int offset = 0;
- if (args.Length() > 2 && args[2]->IsNumber()) {
- offset = args[2].As<Integer>()->Value();
- }
- Local<ArrayBuffer> arrayBuffer = args[1].As<ArrayBuffer>();
- std::shared_ptr<BackingStore> backing = arrayBuffer->GetBackingStore();
- V8_CONTEXT
- Environment *env = Environment::GetEnvByContext(context);
- struct io_uring_info *io_uring_data = env->GetIOUringData();
- struct request *req;
- // 文件操作對應的request結(jié)構體
- struct io_request *io_req = (struct io_request *)malloc(sizeof(struct io_request));
- memset(io_req, 0, sizeof(*io_req));
- io_req->buf = backing->Data();
- io_req->len = backing->ByteLength();
- io_req->fd = fd;
- io_req->offset = offset;
- req = (struct request *)io_req;
- // JS層回調(diào)
- req->cb = makeCallback<onread>;
- req->op = op;
- // 保存回調(diào)上下文
- if (args.Length() > 3 && args[3]->IsFunction()) {
- Local<Object> obj = Object::New(isolate);
- Local<String> key = newStringToLcal(isolate, onread);
- obj->Set(context, key, args[3].As<Function>());
- req->data = (void *)new RequestContext(env, obj);
- } else {
- req->data = (void *)new RequestContext(env, Local<Function>());
- }
- // 提交請求
- SubmitRequest((struct request *)req, io_uring_data);
- }
初始化請求的上下文后,調(diào)用SubmitRequest提交任務和io_uring。我們看看SubmitRequest。
- void No::io_uring::SubmitRequest(struct request * req, struct io_uring_info *io_uring_data) {
- // 獲取一個io_uring的請求結(jié)構體
- struct io_uring_sqe *sqe = io_uring_get_sqe(&io_uring_data->ring);
- // 填充請求
- switch (req->op)
- {
- case IORING_OP_READ:
- {
- struct io_request * file_req = (struct io_request *)req;
- io_uring_prep_read(sqe, file_req->fd, file_req->buf, file_req->len, file_req->offset);
- break;
- }
- default:
- return;
- }
- ++io_uring_data->pending;
- // 保存請求上下文,響應的時候用
- io_uring_sqe_set_data(sqe, (void *)req);
- io_uring_submit(&io_uring_data->ring);
- }
SubmitRequest根據(jù)不同的操作設置io_uring的請求結(jié)構體,并保存對應的請求上下文。當任務完成時執(zhí)行回調(diào)makeCallback。makeCallback是模板函數(shù)。
- template <const char * event>
- void makeCallback(void * req) {
- struct request * _req = (struct request *)req;
- RequestContext* ctx =(RequestContext *)_req->data;
- if (!ctx->object.IsEmpty()) {
- Local<Object> object = ctx->object.Get(ctx->env->GetIsolate());
- Local<Value> cb;
- Local<Context> context = ctx->env->GetContext();
- Local<String> onevent = newStringToLcal(ctx->env->GetIsolate(), event);
- object->Get(context, onevent).ToLocal(&cb);
- if (cb->IsFunction()) {
- Local<Value> argv[] = {
- Integer::New(context->GetIsolate(), _req->res)
- };
- // 執(zhí)行JS層回調(diào)
- cb.As<v8::Function>()->Call(context, object, 1, argv);
- }
- }
- };
makeCallback做的事情就是執(zhí)行JS回調(diào)。
4 非io_uring的處理
io_uring目前已經(jīng)支持了非常多的操作,但我們也不可避免地會碰到io_uring不支持的操作,比如信號的處理。No里目前定時器和信號不是使用io_uring處理的。定時器目前使用內(nèi)核的posix timer實現(xiàn)的,io_uring有個timeout類型的請求,可能會使用io_uring的,信號處理io_uring就無能無力了。因為No是單線程的架構,所以非io_uring的任務完成后也需要通過io_uring事件循環(huán)執(zhí)行,下面看一下非io_uring支持的操作如何處理的。在業(yè)務里,我們可能需要監(jiān)聽一個信號。
- const {
- signal,
- console,
- process,
- timer,} = No;
- signal.on(signal.constant.SIG.SIGUSR1, () => {
- process.exit();
- });
- // for keep process alive
- timer.setInterval(() => {},10000, 10000);
可以通過signal模塊的on實現(xiàn)監(jiān)聽信號,接下來看看具體實現(xiàn)。
- void No::Signal::RegisterSignal(V8_ARGS) {
- V8_ISOLATE
- V8_CONTEXT
- Environment *env = Environment::GetEnvByContext(context);
- Local<Object> obj = Object::New(isolate);
- Local<String> key = newStringToLcal(isolate, onsignal);
- obj->Set(context, key, args[1].As<Function>());
- int sig = args[0].As<Integer>()->Value();
- // 新建一個上下文
- shared_ptr<SignalRequestContext> ctx = make_shared<SignalRequestContext>(env, obj, sig);
- auto ret = signalMap.find(sig);
- // 是否在map里,不是則新建一個vector,否則直接追加
- if (ret == signalMap.end()) {
- signal(sig, signalHandler);
- vector<shared_ptr<SignalRequestContext>> vec;
- vec.push_back(ctx);
- signalMap.insert(map<int, vector<shared_ptr<SignalRequestContext>>>::value_type (sig, vec));
- return;
- }
- ret->second.push_back(ctx);
- }
No使用一個map管理信號和監(jiān)聽函數(shù),因為支持多個監(jiān)聽函數(shù),所以map的key是信號的值,value是一個回調(diào)函數(shù)數(shù)組。如果是第一次注冊該信號,則調(diào)用signal注冊該信號的處理函數(shù),所有信號的處理函數(shù)都是signalHandler。接著看信號產(chǎn)生時的處理邏輯。
- static void signalHandler(int signum){
- auto vec = signalMap.find(signum);
- if (vec != signalMap.end()) {
- vector<shared_ptr<SignalRequestContext>>::iterator it;
- for(it=vec->second.begin();it!=vec->second.end(); it++)
- {
- struct signal_request * req = (struct signal_request *)malloc(sizeof(*req));
- memset(req, 0, sizeof(*req));
- req->cb = signal_cb;
- req->data = (void *)(*it).get();
- req->op = IORING_OP_NOP;
- SubmitRequest((struct request *)req, (*it).get()->env->GetIOUringData());
- }
- }
- }
信號產(chǎn)生時,從map中找到對應的處理函數(shù)列表,然后生成一個io_uring請求,這樣在事件循環(huán)時就會被執(zhí)行,也實現(xiàn)了非io_uring任務和io_uring任務的整合,這里主要是利用了io_uring提供了nop類型的請求,這個類型的請求不做任何操作,主要是用于測試io_uring請求和響應鏈路,利用這點恰好可以實現(xiàn)我們的需求。從代碼中可以看到io_uring事件循環(huán)時會執(zhí)行信號處理的回調(diào)signal_cb,signal_cb會回調(diào)JS層。
5 上下文的設計
因為No各種請求都是異步的,所以避免不了需要保持請求和響應的上下文。類似Node.js,No里也存在一個env作為整個進程級的上下文。
- enum {
- CONTEXT_INDEX
- } ENV_INDEX;
- class Environment {
- public:
- Environment(Local<Context> context);
- static Environment * GetEnvByContext(Local<Context> context);
- struct io_uring_info * GetIOUringData() {
- return io_uring_data;
- }
- Isolate * GetIsolate() const {
- return _isolate;
- }
- Local<Context> GetContext() const {
- return PersistentToLocal::Strong(_context);
- }
- private:
- struct io_uring_info *io_uring_data;
- Global<Context> _context;
- Isolate * _isolate;
- };
env目前的功能還不多,只要負責管理context、isolate、io_uring等數(shù)據(jù)結(jié)構。另外還有一些和具體操作相關的上下文。
- struct RequestContext {
- RequestContext(Environment * passEnv, Local<Object> _object)
- : env(passEnv), object(passEnv->GetIsolate(), _object) {}
- ~RequestContext() {
- if (!object.IsEmpty()) {
- object.Reset();
- }
- }
- Environment * env;
- Global<Object> object;
- };
- struct SignalRequestContext: public RequestContext
- {
- SignalRequestContext(Environment * passEnv, Local<Object> _object, int _sig)
- : RequestContext(passEnv, _object), sig(_sig) {}
- int sig;
- };
前面介紹過io_uring層的上下文request,request主要是用于io_uring任務完成時,知道執(zhí)行哪個回調(diào)函數(shù),并且記錄了少量的上下文,但是reuqest的字段不一定夠用,所以RequestContext主要記錄額外的上下文,其實把RequestContext的字段合進request也是可以的。
6 事件循環(huán)的設計
No的事件循環(huán)是io_uring實現(xiàn)的,事件循環(huán)的本質(zhì)就是在一個循環(huán)里不斷等待任務和執(zhí)行任務,那么什么時候結(jié)束呢?
- while(io_uring_data->stop != 1 && io_uring_data->pending != 0) {
- // 等待和處理任務
- }
目前可以通過設置stop直接停止事件循環(huán),正常情況下,沒有任務了就會結(jié)束事件循環(huán),通過pending字段記錄,比如發(fā)起一個讀取文件的請求,pending就是1,讀完后就會減一,這時候,事件循環(huán)就會結(jié)束,相對Node.js的handle和request,No里是沒有的,No里通過控制pending的值去控制事件循環(huán)的狀態(tài)。
7 如何使用
No是基于Linux的io_uring的,目前在Linux5.5及以上的系統(tǒng)可以運行,可以安裝ubuntu21.04及以上的虛擬機使用,具體可以參考倉庫說明(https://github.com/theanarkh/No.js)。目前支持了TCP、UCP、Unix域、文件、信號、定時器、log,進程還沒有寫完,總體只是支持一些簡單的操作,后續(xù)慢慢更新。
8 后記
寫No是一個讓人非常深刻的過程,已經(jīng)很多年沒有正經(jīng)寫過c、c++代碼,或許代碼里有不對的用法,但是整個過程里的思考、編碼和調(diào)試讓我學到了很多東西,也給我了一段深刻的時光。目前實現(xiàn)的功能還不多,也不足以用起來,還有很多事情需要做,紙上得來終覺淺,絕知此事要躬行。后續(xù)慢慢學習、慢慢思考、慢慢更新!