Threading di Node.js: module worker_threads

Pada 18 Januari, platform Node.js versi 11.7.0 diumumkan . Di antara perubahan-perubahan penting dalam versi ini, orang dapat mencatat kesimpulan dari kategori module_ laboratorial modul eksperimental, yang muncul di Node.js 10.5.0 . Sekarang bendera - pekerja eksperimental tidak diperlukan untuk menggunakannya. Modul ini, sejak awal, tetap cukup stabil, dan karenanya keputusan dibuat, tercermin dalam Node.js 11.7.0.

Penulis materi, terjemahan yang kami terbitkan, menawarkan untuk membahas kemampuan modul worker_threads, khususnya, ia ingin berbicara tentang mengapa modul ini diperlukan, dan bagaimana multithreading diimplementasikan dalam JavaScript dan Node.js karena alasan historis. Di sini kita akan berbicara tentang masalah apa yang terkait dengan penulisan aplikasi JS multi-utas, tentang cara yang ada untuk menyelesaikannya, dan tentang masa depan pemrosesan data paralel menggunakan apa yang disebut "utas pekerja", yang kadang-kadang disebut "utas pekerja" atau hanya "pekerja."

Hidup di dunia single-threaded


JavaScript dipahami sebagai bahasa pemrograman single-threaded yang berjalan di browser. "Single-threaded" berarti bahwa dalam proses yang sama (di browser modern kita berbicara tentang tab browser yang terpisah), hanya satu set instruksi yang dapat dieksekusi pada suatu waktu.

Ini menyederhanakan pengembangan aplikasi, memfasilitasi pekerjaan programmer. Awalnya, JavaScript adalah bahasa yang hanya cocok untuk menambahkan beberapa fitur interaktif ke halaman web, misalnya, seperti validasi formulir. Di antara tugas-tugas yang dirancang untuk JS, tidak ada hal rumit yang membutuhkan multithreading.

Ryan Dahl , pencipta Node.js, melihat peluang menarik dalam pembatasan bahasa ini. Dia ingin mengimplementasikan platform server berdasarkan pada subsistem I / O asinkron. Ini berarti bahwa programmer tidak perlu bekerja dengan utas, yang sangat menyederhanakan pengembangan untuk platform yang sama. Ketika mengembangkan program yang dirancang untuk eksekusi kode paralel, masalah mungkin timbul yang sangat sulit untuk dipecahkan. Katakanlah, jika beberapa utas mencoba mengakses area memori yang sama, ini dapat mengarah pada apa yang disebut "proses perlombaan" yang mengganggu program. Kesalahan seperti itu sulit untuk direproduksi dan diperbaiki.

Apakah platform Node.js beralur tunggal?


Apakah aplikasi Node.js beralur tunggal? Ya, memang demikian adanya. Sebenarnya, Node.js memungkinkan Anda untuk melakukan tindakan tertentu secara paralel, tetapi untuk ini, programmer tidak perlu membuat utas atau menyinkronkannya. Platform Node.js dan sistem operasi melakukan operasi input / output paralel dengan caranya sendiri, dan ketika saatnya tiba untuk pemrosesan data menggunakan kode JavaScript kami, ia bekerja dalam mode single-threaded.

Dengan kata lain, semuanya kecuali kode JS kami bekerja secara paralel. Dalam blok kode JavaScript yang sinkron, perintah selalu dijalankan satu per satu, sesuai urutan yang disajikan dalam kode sumber:

let flag = false function doSomething() {  flag = true  //    -  (     flag)...  //      ,     flag   true.  // -       ,  //      . } 

Semua ini luar biasa - jika semua kode kita sibuk melakukan I / O asinkron. Program ini terdiri dari blok kecil kode sinkron yang dengan cepat beroperasi pada data, misalnya, dikirim ke file dan stream. Kode fragmen program sangat cepat sehingga tidak memblokir eksekusi kode fragmen lainnya. Jauh lebih banyak waktu daripada eksekusi kode yang dibutuhkan untuk menunggu hasil dari I / O yang tidak sinkron. Pertimbangkan sebuah contoh kecil:

 db.findOne('SELECT ... LIMIT 1', function(err, result) { if (err) return console.error(err) console.log(result) }) console.log('Running query') setTimeout(function() { console.log('Hey there') }, 1000) 

Ada kemungkinan bahwa kueri ke database yang ditampilkan di sini akan memakan waktu sekitar satu menit, tetapi pesan Running query akan dikirim ke konsol segera setelah permintaan ini dimulai. Dalam hal ini, pesan Hey there akan ditampilkan satu detik setelah permintaan dieksekusi, terlepas dari apakah eksekusi telah selesai atau belum. Aplikasi Node.js kami hanya memanggil fungsi yang menginisiasi permintaan, sementara eksekusi kode lainnya tidak diblokir. Setelah permintaan selesai, aplikasi akan diinformasikan tentang hal ini menggunakan fungsi panggilan balik, dan kemudian akan menerima tanggapan terhadap permintaan ini.

Tugas intensif CPU


Apa yang terjadi jika kita, melalui JavaScript, perlu melakukan komputasi berat? Misalnya - untuk memproses set besar data yang disimpan dalam memori? Hal ini dapat mengarah pada fakta bahwa program akan berisi sebuah fragmen kode sinkron, pelaksanaannya membutuhkan banyak waktu dan memblokir eksekusi kode lainnya. Bayangkan perhitungan ini membutuhkan waktu 10 detik. Jika kita berbicara tentang server web yang memproses permintaan tertentu, ini berarti bahwa itu tidak akan dapat memproses permintaan lain selama setidaknya 10 detik. Ini masalah besar. Bahkan, perhitungan yang lebih dari 100 milidetik sudah dapat menyebabkan masalah ini.

JavaScript dan platform Node.js pada awalnya tidak dirancang untuk menyelesaikan tugas yang menggunakan sumber daya prosesor secara intensif. Dalam kasus JS yang berjalan di browser, melakukan tugas-tugas seperti itu berarti "rem" pada antarmuka pengguna. Di Node.js, ini dapat membatasi kemampuan untuk meminta platform untuk melakukan tugas I / O asinkron baru dan kemampuan untuk merespons peristiwa yang terkait dengan penyelesaiannya.

Mari kita kembali ke contoh sebelumnya. Bayangkan bahwa sebagai respons terhadap permintaan ke database, beberapa ribu catatan terenkripsi masuk, yang, dalam kode JS sinkron, harus didekripsi:

 db.findAll('SELECT ...', function(err, results) { if (err) return console.error(err) //      ,    . for (const encrypted of results) {   const plainText = decrypt(encrypted)   console.log(plainText) } }) 

Hasilnya, setelah menerimanya, ada dalam fungsi panggilan balik. Setelah itu, hingga akhir pemrosesan, tidak ada kode JS lain yang dapat dieksekusi. Biasanya, seperti yang telah disebutkan, beban pada sistem yang dibuat oleh kode tersebut minimal, dengan cepat melakukan tugas yang diberikan kepadanya. Namun dalam kasus ini, program menerima hasil kueri, yang memiliki jumlah yang cukup besar, dan kami masih perlu memprosesnya. Sesuatu seperti ini bisa memakan waktu beberapa detik. Jika kita berbicara tentang server tempat banyak pengguna bekerja, ini berarti mereka dapat terus bekerja hanya setelah selesainya operasi yang intensif sumber daya.

Mengapa JavaScript tidak pernah memiliki utas?


Mengingat hal di atas, mungkin untuk menyelesaikan masalah komputasi berat di Node.js Anda perlu menambahkan modul baru yang akan memungkinkan Anda membuat utas dan mengelolanya. Bagaimana Anda bisa melakukannya tanpa hal seperti itu? Sangat menyedihkan bahwa mereka yang menggunakan platform server yang matang, seperti Node.js, tidak memiliki sarana untuk menyelesaikan masalah yang terkait dengan pemrosesan sejumlah besar data dengan indah.

Semua ini benar, tetapi jika Anda menambahkan kemampuan untuk bekerja dengan stream dalam JavaScript, ini akan menyebabkan perubahan pada sifat dari bahasa ini. Di JS, Anda tidak bisa hanya menambahkan kemampuan untuk bekerja dengan utas, katakanlah, dalam bentuk beberapa kelas atau fungsi baru. Untuk melakukan ini, Anda perlu mengubah bahasa itu sendiri. Dalam bahasa yang mendukung multithreading, konsep sinkronisasi banyak digunakan. Sebagai contoh, di Jawa, bahkan beberapa tipe numerik tidak bersifat atom. Ini berarti bahwa jika mekanisme sinkronisasi tidak digunakan untuk bekerja dengannya dari utas berbeda, semua ini dapat menghasilkan, misalnya, setelah beberapa utas secara bersamaan mencoba mengubah nilai variabel yang sama, beberapa byte dari variabel tersebut akan disetel ke satu mengalir, dan beberapa lainnya. Akibatnya, variabel tersebut akan berisi sesuatu yang tidak sesuai dengan operasi normal program.

Solusi primitif untuk masalah: iterasi dari loop acara


Node.js tidak akan menjalankan blok kode berikutnya dalam antrian acara hingga blok sebelumnya selesai. Ini berarti bahwa untuk menyelesaikan masalah kami, kami dapat memecahnya menjadi bagian-bagian yang diwakili oleh fragmen kode sinkron, dan kemudian menggunakan konstruksi dari bentuk setImmediate(callback) untuk merencanakan eksekusi fragmen ini. Kode yang ditentukan oleh fungsi callback dalam konstruksi ini akan dieksekusi setelah tugas iterasi saat ini (centang) dari loop acara selesai. Setelah itu, desain yang sama digunakan untuk mengantri kumpulan perhitungan berikutnya. Ini memungkinkan untuk tidak memblokir siklus peristiwa dan, pada saat yang sama, memecahkan masalah volumetrik.

Bayangkan bahwa kita memiliki array besar yang perlu diproses, sementara memproses setiap elemen dari array tersebut membutuhkan perhitungan yang kompleks:

 const arr = [/*large array*/] for (const item of arr) { //         } // ,   ,      . 

Seperti yang telah disebutkan, jika kita memutuskan untuk memproses seluruh array dalam satu panggilan, akan terlalu banyak waktu dan mencegah eksekusi kode aplikasi lain. Oleh karena itu, kami akan membagi tugas besar ini menjadi beberapa bagian dan menggunakan setImmediate(callback) :

 const crypto = require('crypto') const arr = new Array(200).fill('something') function processChunk() { if (arr.length === 0) {   // ,      } else {   console.log('processing chunk');   //  10         const subarr = arr.splice(0, 10)   for (const item of subarr) {     //           doHeavyStuff(item)   }   //       setImmediate(processChunk) } } processChunk() function doHeavyStuff(item) { crypto.createHmac('sha256', 'secret').update(new Array(10000).fill(item).join('.')).digest('hex') } //       , ,   , //       . let interval = setInterval(() => { console.log('tick!') if (arr.length === 0) clearInterval(interval) }, 0) 

Sekarang, dalam sekali jalan, kami memproses sepuluh elemen array, setelah itu, menggunakan setImmediate() , kami merencanakan kumpulan perhitungan berikutnya. Dan ini berarti bahwa jika Anda perlu mengeksekusi beberapa kode lagi dalam program, itu dapat dieksekusi antara operasi pada pemrosesan fragmen array. Untuk ini, di sini, di akhir contoh, ada kode yang menggunakan setInterval() .

Seperti yang Anda lihat, kode seperti itu terlihat jauh lebih rumit daripada versi aslinya. Dan seringkali algoritme dapat menjadi jauh lebih kompleks daripada kita, yang berarti bahwa, ketika diimplementasikan, tidak akan mudah untuk memecah perhitungan menjadi beberapa bagian dan memahami di mana, untuk mencapai keseimbangan yang tepat, Anda perlu mengatur setImmediate() , merencanakan bagian perhitungan berikutnya. Selain itu, kode sekarang ternyata tidak sinkron, dan jika proyek kami bergantung pada pustaka pihak ketiga, maka kami mungkin tidak dapat memecah proses penyelesaian tugas yang sulit menjadi beberapa bagian.

Proses latar belakang


Mungkin pendekatan di atas dengan setImmediate() akan berfungsi dengan baik untuk kasus-kasus sederhana, tetapi jauh dari ideal. Selain itu, utas tidak digunakan di sini (untuk alasan yang jelas) dan kami juga tidak bermaksud mengubah bahasa untuk ini. Apakah mungkin untuk melakukan pemrosesan data paralel tanpa menggunakan utas? Ya, itu mungkin, dan untuk ini kita perlu semacam mekanisme untuk pemrosesan data latar belakang. Ini adalah tentang memulai tugas tertentu, meneruskan data ke sana, dan agar tugas ini, tanpa mengganggu kode utama, menggunakan semua yang diperlukan, menghabiskan waktu sebanyak yang diperlukan untuk bekerja, dan kemudian mengembalikan hasilnya ke kode utama. Kami memerlukan sesuatu yang mirip dengan cuplikan kode berikut:

 //  script.js   ,    . const service = createService('script.js') //          service.compute(data, function(err, result) { //      }) 

Kenyataannya adalah bahwa di Node.js Anda dapat menggunakan proses latar belakang. Intinya adalah bahwa dimungkinkan untuk membuat percabangan proses dan mengimplementasikan skema kerja yang dijelaskan di atas menggunakan mekanisme pengiriman pesan antara proses anak dan orang tua. Proses utama dapat berinteraksi dengan proses keturunan, mengirimkan peristiwa ke sana dan menerima mereka darinya. Memori bersama tidak digunakan dengan pendekatan ini. Semua data yang dipertukarkan oleh proses adalah "kloning", yaitu, ketika perubahan dibuat untuk contoh data ini dengan satu proses, perubahan ini tidak terlihat oleh proses lain. Ini mirip dengan permintaan HTTP - ketika klien mengirimkannya ke server, server hanya menerima salinannya. Jika proses tidak menggunakan memori bersama, ini berarti bahwa dengan operasi simultan mereka tidak mungkin untuk membuat "negara ras", dan bahwa kita tidak perlu membebani diri kita sendiri dengan bekerja dengan utas. Sepertinya masalah kita telah teratasi.

Benar, pada kenyataannya tidak demikian. Ya - di depan kami adalah salah satu solusi untuk tugas melakukan perhitungan intensif, tetapi sekali lagi, itu tidak sempurna. Membuat garpu proses adalah operasi yang intensif sumber daya. Butuh waktu untuk menyelesaikannya. Bahkan, kita berbicara tentang membuat mesin virtual baru dari awal dan tentang meningkatkan jumlah memori yang dikonsumsi oleh program, yang disebabkan oleh fakta bahwa proses tidak menggunakan memori bersama. Mengingat hal di atas, adalah tepat untuk bertanya apakah mungkin, setelah menyelesaikan tugas, untuk menggunakan kembali proses garpu. Anda dapat memberikan jawaban positif untuk pertanyaan ini, tetapi di sini Anda harus ingat bahwa sudah direncanakan untuk mentransfer percabangan proses ke berbagai tugas intensif sumber daya yang akan dilakukan di dalamnya secara serempak. Dua masalah bisa dilihat di sini:

  • Meskipun dengan pendekatan ini proses utama tidak diblokir, proses keturunan mampu melakukan tugas yang ditransfer ke sana hanya secara berurutan. Jika kita memiliki dua tugas, yang salah satunya membutuhkan waktu 10 detik, dan yang kedua membutuhkan waktu 1 detik, dan kita akan menyelesaikannya dalam urutan ini, maka kita tidak mungkin menyukai kebutuhan untuk menunggu yang pertama diselesaikan sebelum yang kedua. Karena kami membuat garpu proses, kami ingin menggunakan kemampuan sistem operasi untuk merencanakan tugas dan menggunakan sumber daya komputasi dari semua inti prosesor kami. Kami membutuhkan sesuatu yang menyerupai bekerja di komputer untuk orang yang mendengarkan musik dan melakukan perjalanan melalui halaman web. Untuk melakukan ini, Anda dapat membuat dua proses percabangan dan mengatur pelaksanaan tugas-tugas paralel dengan bantuan mereka.
  • Selain itu, jika salah satu tugas mengarah ke akhir proses dengan kesalahan, semua tugas yang dikirim ke proses tersebut akan diproses.

Untuk menyelesaikan masalah ini, kita memerlukan beberapa proses percabangan, bukan satu, tetapi kita harus membatasi jumlahnya, karena masing-masing membutuhkan sumber daya sistem dan perlu waktu untuk membuatnya masing-masing. Sebagai hasilnya, mengikuti pola sistem yang mendukung koneksi basis data, kita memerlukan sesuatu seperti kumpulan proses yang siap digunakan. Sistem manajemen kumpulan proses, setelah menerima tugas baru, akan menggunakan proses bebas untuk mengeksekusinya, dan ketika proses tertentu mengatasi tugas tersebut, ia akan dapat menetapkan yang baru untuknya. Ada perasaan bahwa skema kerja semacam itu tidak mudah untuk diimplementasikan, dan, pada kenyataannya, memang demikian. Kami akan menggunakan paket pekerja-pertanian untuk mengimplementasikan skema ini:

 //   const workerFarm = require('worker-farm') const service = workerFarm(require.resolve('./script')) service('hello', function (err, output) { console.log(output) }) // script.js //      - module.exports = (input, callback) => { callback(null, input + ' ' + world) } 

Modul Worker_threads


Jadi, apakah masalah kita terselesaikan? Ya, kami dapat mengatakan bahwa itu terpecahkan, tetapi dengan pendekatan ini, diperlukan lebih banyak memori daripada yang diperlukan jika kami memiliki solusi multithreaded yang kami miliki. Thread mengkonsumsi sumber daya yang jauh lebih sedikit dibandingkan dengan garpu proses. Itulah mengapa modul worker_threads muncul di worker_threads

Utas pekerja dijalankan dalam konteks yang terisolasi. Mereka bertukar informasi dengan proses utama menggunakan pesan. Ini menyelamatkan kita dari masalah “kondisi balapan” yang harus dihadapi oleh lingkungan multi-utas. Pada saat yang sama, aliran pekerja ada dalam proses yang sama dengan program utama, yaitu, dengan pendekatan ini, dibandingkan dengan penggunaan proses garpu, memori yang digunakan lebih sedikit.

Selain itu, bekerja dengan pekerja, Anda dapat menggunakan memori bersama. Jadi, khusus untuk tujuan ini, objek dari tipe SharedArrayBuffer . Mereka harus digunakan hanya dalam kasus-kasus ketika program perlu melakukan pemrosesan kompleks sejumlah besar data. Mereka memungkinkan Anda untuk menyimpan sumber daya yang diperlukan untuk membuat serial dan deserialisasi data saat mengatur pertukaran data antara pekerja dan program utama melalui pesan.

Arus Pekerja Pekerja


Jika Anda menggunakan platform Node.js sebelum versi 11.7.0, untuk mengaktifkan pekerjaan dengan modul worker_threads , Anda perlu menggunakan --experimental-worker ketika memulai --experimental-worker

Selain itu, perlu diingat bahwa menciptakan pekerja (seperti membuat utas dalam bahasa apa pun), meskipun membutuhkan jauh lebih sedikit sumber daya daripada membuat garpu proses, juga menciptakan beban tertentu pada sistem. Mungkin dalam kasus Anda, bahkan beban ini mungkin terlalu banyak. Dalam kasus seperti itu, dokumentasi merekomendasikan membuat kumpulan pekerja. Jika Anda memerlukan ini, maka tentu saja Anda dapat membuat implementasi Anda sendiri dari mekanisme semacam itu, tetapi mungkin Anda harus mencari sesuatu yang cocok di registri NPM.

Pertimbangkan contoh bekerja dengan utas pekerja. Kami akan memiliki file utama, index.js , di mana kami akan membuat utas pekerja dan memberikannya beberapa data untuk diproses. API yang sesuai adalah berbasis peristiwa, tapi saya akan menggunakan janji di sini yang menyelesaikan ketika pesan pertama dari pekerja tiba:

 // index.js //    Node.js   11.7.0,  //      node --experimental-worker index.js const { Worker } = require('worker_threads') function runService(workerData) { return new Promise((resolve, reject) => {   const worker = new Worker('./service.js', { workerData });   worker.on('message', resolve);   worker.on('error', reject);   worker.on('exit', (code) => {     if (code !== 0)       reject(new Error(`Worker stopped with exit code ${code}`));   }) }) } async function run() { const result = await runService('world') console.log(result); } run().catch(err => console.error(err)) 

Seperti yang Anda lihat, menggunakan mekanisme alur kerja cukup sederhana. Yaitu, saat membuat pekerja, Anda harus meneruskan jalur ke file dengan kode pekerja dan data ke desainer Worker . Ingat bahwa data ini dikloning, tidak disimpan dalam memori bersama. Setelah memulai pekerja, kami mengharapkan pesan darinya, mendengarkan acara message .

Di atas, saat membuat objek bertipe Worker , kami meneruskan konstruktor nama file dengan kode pekerja - service.js . Ini kode untuk file ini:

 const { workerData, parentPort } = require('worker_threads') // , ,    , //    . parentPort.postMessage({ hello: workerData }) 

Ada dua hal yang menarik bagi kita dalam kode pekerja. Pertama, kita membutuhkan data yang dikirimkan oleh aplikasi utama. Dalam kasus kami, mereka diwakili oleh variabel data workerData . Kedua, kita memerlukan mekanisme untuk mengirimkan informasi ke aplikasi utama. Mekanisme ini diwakili oleh objek parentPort , yang memiliki metode postMessage() , yang digunakan untuk meneruskan hasil pemrosesan data ke aplikasi utama. Begitulah cara kerjanya.

Ini adalah contoh yang sangat sederhana, tetapi dengan menggunakan mekanisme yang sama Anda dapat membangun struktur yang jauh lebih kompleks. Misalnya, dari aliran pekerja, Anda dapat mengirim banyak pesan ke arus utama yang membawa informasi tentang status pemrosesan data jika aplikasi kami membutuhkan mekanisme yang sama. Bahkan dari pekerja, hasil pemrosesan data dapat dikembalikan sebagian. Misalnya, sesuatu seperti ini dapat berguna dalam situasi ketika seorang pekerja sedang sibuk, misalnya, memproses ribuan gambar, dan Anda, tanpa menunggu semuanya diproses, ingin memberi tahu aplikasi utama yang masing-masing telah selesai diproses.

Detail tentang modul worker_threads dapat ditemukan di sini .

Pekerja web


Anda mungkin pernah mendengar tentang pekerja web. Mereka dimaksudkan untuk digunakan dalam lingkungan klien, teknologi ini telah ada sejak lama dan menikmati dukungan yang baik untuk browser modern. API untuk bekerja dengan pekerja web berbeda dari apa yang diberikan modul Node.js pada worker_threads , ini semua tentang perbedaan lingkungan tempat mereka bekerja. Namun, teknologi ini dapat menyelesaikan masalah serupa. Misalnya, pekerja web dapat digunakan dalam aplikasi klien untuk melakukan enkripsi dan dekripsi data, kompresi dan dekompresi mereka. Dengan bantuan mereka, Anda dapat memproses gambar, menerapkan sistem visi komputer (misalnya, kita berbicara tentang pengenalan wajah) dan menyelesaikan masalah serupa lainnya di browser.

Ringkasan


worker_threads — Node.js. , , . , , , « ». , ? , worker_threads , Node.js worker-farm , worker_threads , Node.js .

Pembaca yang budiman! Node.js-?

Source: https://habr.com/ru/post/id437984/


All Articles