BerandaComputers and TechnologyFearless Concurrency: Clojure, Rust, Pony, Erlang, dan Dart

Fearless Concurrency: Clojure, Rust, Pony, Erlang, dan Dart

diposkan 24 Feb 2019, 9:13 AM oleh Renato Athaydes [ updated Feb 24, 2019, 1:42 PM]

Siapapun yang telah menulis kode bersamaan (yaitu kode dimana lebih dari satu Thread eksekusi hadir) yang melibatkan lebih dari beberapa variabel yang dapat berubah tahu bahwa sangat sulit untuk melakukannya dengan benar menggunakan alat tingkat rendah seperti kunci dan semaphore.

Menguji kode semacam ini bahkan lebih sulit: Anda dipaksa untuk menulis tes probabilistik (yaitu tes yang dapat membuktikan keberadaan bug, tetapi tidak pernah tidak ada dengan kepastian 100% bahkan setelah sejumlah besar berjalan) atau menggunakan alat dirancang khusus untuk menguji konkurensi (yang bisa sangat rumit sendiri).

Sebagian besar pengembang yang masuk akal cenderung menggunakan primitif konkurensi tingkat lebih tinggi (thread-local, concurrent coll ections dll) untuk menulis kode bersamaan jika memungkinkan. Masalahnya, terkadang, sayangnya, alat-alat ini tidak cukup, masih mudah untuk menembak kaki Anda sendiri dan tersesat dalam lautan kerumitan.

Untuk alasan ini, beberapa model yang memudahkan alasan tentang konkurensi program telah dibayangkan dari waktu ke waktu.

Dalam artikel ini, kita akan melihat sekilas beberapa di antaranya, dari bahasa baru hingga bahasa yang tidak terlalu baru. Saya tidak bermaksud untuk memberikan analisis ekstensif dari setiap solusi, atau membuat perbandingan formal di antara mereka. Tujuan saya adalah untuk menjelaskan dasar-dasar setiap solusi dan bagaimana mereka dapat digunakan dalam praktik (dengan contoh kode yang menunjukkan seperti apa hasil dari penggunaan model), sehingga pengembang lain dapat lebih mudah memahaminya dan memutuskan solusi atau bahasa mana yang mungkin lebih baik diterapkan pada masalah tertentu mereka.

Konkurensi dalam artikel ini dimaksudkan untuk merujuk pada pelaksanaan tugas multi-threaded mungkin beberapa CPU, atau bahkan mesin.

Ini menyiratkan setidaknya kemungkinan paralelisme. Lihat Concurrency VS Parallelism untuk mengetahui seluk-beluk perbedaannya.

Model konkurensi alternatif

Kekekalan, Pemrograman Fungsional (Clojure)

Alternatif populer untuk membuat kode konkuren aman dan lebih mudah dipahami adalah dengan hanya menghapus variabel yang dapat berubah seluruhnya dari persamaan.

Lagi pula, ketika semua hal tidak dapat diubah, konkurensi menjadi jauh lebih mudah dikelola, seperti yang telah ditunjukkan oleh bahasa pemrograman fungsional untuk waktu yang lama. Status bersama tidak menjadi masalah kecuali yg mungkin berubah negara bagian.

Tampaknya tidak mungkin untuk menulis aplikasi hanya menggunakan struktur data yang tidak dapat diubah jika Anda tidak terbiasa dengan pemrograman fungsional, tetapi itu jelas bukan masalahnya. Untuk memahami caranya, mari kita lihat contoh yang sangat sederhana.

Contoh berikut menunjukkan bagaimana Clojure memungkinkan penggantian beberapa elemen dari daftar tetap (atau vektor):

( def vec-saya [0 1 2 3 4 5] )

; ganti elemen pada indeks 3 dengan x

( def new-vec ( assoc vec-saya 3 : x))

( println vec-ku)

( println vec baru)

Hasil:

[0 1 2 3 4 5]

[0 1 2 😡 4 5]

Vektor asli, vec-saya , tidak dimodifikasi. Ini tidak berubah, bagaimanapun juga … tetapi kita dapat membuat vektor lain, new-vec , yang berisi konten vektor asli, tetapi dengan satu atau lebih elemen diganti, seperti dalam contoh ini di mana elemen ke-4 diganti dengan : x .

Program yang ditulis hanya menggunakan struktur data yang tidak dapat diubah selalu harus bekerja dengan cara ini: alih-alih mengubah sesuatu, Anda menerapkan fungsi untuk mendapatkannya referensi baru untuk sesuatu yang “dimodifikasi”. Untuk bagian lain dari kode untuk “melihat” perubahan, mereka harus secara eksplisit meminta referensi baru, karena salinan yang mungkin mereka miliki tidak akan pernah berubah.

Program semacam itu dapat diparalelkan secara sepele karena tidak perlu menyinkronkan akses ke status yang tidak dapat diubah (dan tidak ada status yang dapat berubah!).

Itu Contoh di bawah ini menunjukkan bagaimana kita dapat mengubah setiap elemen vektor dengan menjalankan beberapa perhitungan mahal pada Thread yang berbeda untuk setiap item (penjelasan di bawah):

( defn random-long-between

[min max]

( def max-rand ( maks min))

(panjang ( + mnt ( rand max-rand))))

( defn operasi mahal

[n]

( membiarkan [wait-millis (random-long-between 250 750)]

( Benang / tidur tunggu-milidetik)

{: nn: even? ( bahkan? n): tunda tunggu-milis}))

( defn eager-map-async

“Menerapkan kesenangan pada setiap elemen dari urutan yang diberikan pada Thread terpisah,

mengembalikan vektor dengan hasil. “

[fun sequence]

; masa depan menerapkan fungsi yang diberikan secara asinkron di Thread lain

(membiarkan [async-seq ( peta ( fn [n] (masa depan ( menyenangkan n))) urutan)]

; kami kemudian menggunakan ‘@’ untuk membatalkan referensi setiap masa depan, memasukkan elemen baru ke dalam vektor

( vec ( peta ( fn [fut] @fut) async-seq))))

( def my-vec [0 1 2 3 4 5])

; Terapkan dan atur waktu fungsi eager-map-async di my-vec

( def new-vec ( waktu ( eager-map-async operasi mahal my-vec)))

( lakukan semua (peta println new-vec))

Hasil:


"Berlalu waktu: 622,28397 msecs "

{: n 0,: bahkan? benar,: tunda 616}

{: n 1,: even? salah,: tunda 557}

{: n 2,: even? benar,: tunda 254}

{: n 3,: bahkan? false,: delay 611}

{: n 4,: even? benar,: tunda 584}

{: n 5,: bahkan? false,: delay 257}

The eager-map-async fungsi dalam kode di atas melakukan pekerjaan utama memanggil fungsi yang disediakan, operasi mahal dalam hal ini, di Thread yang berbeda dengan menggunakan masa depan fungsi, bagian dari Clojure SDK. Kemudian memblokir menunggu semua elemen untuk direalisasikan oleh masa depan (menggunakan simbol @ untuk membatalkan referensi di masa mendatang), sebelum mengembalikan vektor yang berisi semua hasil.

Anda bisa melihatnya transformasi vektor penuh dilakukan 622 md hingga selesai, yang diharapkan jika semua operasi berjalan secara paralel, karena penundaan terlama 616 md (mungkin, 6 ms lainnya dihabiskan untuk melakukan pekerjaan yang sebenarnya).

Contoh ini hanya berhubungan dengan fasilitas konkurensi Clojure! Anda dapat mempelajari lebih lanjut tentang konkurensi di Clojure di Brave Clojure buku online.

Mungkin terdengar sangat boros untuk membuat salinan setiap saat , tetapi mungkin untuk menghindari penyalinan sepenuhnya struktur data pada setiap modifikasi melalui pembagian struktural dan teknik lainnya. Clojure struktur data sangat pintar hal itu! Artikel ini di hypirion.com menjelaskan secara rinci cara kerjanya. Jika Anda hanya ingin belajar menggunakannya, purelyfunctional.tv memiliki panduan utama tentang koleksi Clojure.

Tetapi pemrograman yang tidak dapat diubah dan fungsional bukanlah satu-satunya permainan di kota. Ada cara lain untuk menulis aplikasi bersamaan dengan jaminan kebenaran (meskipun mungkin masih sulit untuk menulisnya!).

Bagian selanjutnya menjelaskan secara singkat solusi Rust untuk masalah tersebut.

Salah satu solusi paling menarik untuk masalah konkurensi adalah sistem yang digunakan oleh Rust: the sistem kepemilikan , didukung oleh ( terkenal jahat Pemeriksa pinjaman karat . Dengan sistem ini, Anda dapat menulis kode bersamaan seperti yang biasa Anda lakukan dalam bahasa seperti Java atau C ++, dengan perbedaan bahwa kesalahan apa pun yang Anda buat akan ditangkap oleh pemeriksa peminjam. Jika ada kemungkinan bahwa Anda meneruskan variabel yang bisa berubah ke suatu fungsi, dan variabel itu dapat diakses secara bersamaan dari Thread lain, pemeriksa peminjam Rust akan berteriak kepada Anda dan kode Anda bahkan tidak dapat dikompilasi, apalagi dijalankan. Ini bagus, tetapi biayanya tinggi: program yang dapat dengan mudah ditulis dalam sebagian besar bahasa dapat membebani penulisan di Rust, bahkan ketika mereka aman untuk dijalankan tanpa semua perlindungan yang diminta Rust.

Sistem kepemilikan dirancang terutama sebagai solusi untuk manajemen memori tanpa pengumpul sampah. Namun, sistem juga memecahkan masalah konkurensi, dengan sedikit bantuan sistem tipe, karena manajemen memori dan konkurensi sebenarnya secara intrinsik terkait .

Contoh berikut menunjukkan bagaimana Rust menangani variabel yang bisa berubah (bahkan saat tidak ada konkurensi, seperti di Rust, apa pun terlihat dari semua Thread, jadi kemungkinan akses bersamaan selalu ada):

penggunaan std :: koleksi :: LinkedList;

fn utama () {

biarkan mut daftar: LinkedList u64 > = LinkedList :: baru ();

daftar. push_back ( 1 );

daftar. push_back ( 2 );

daftar. push_back ( 3 );

println! ( “{:?}” , & daftar);

}

Hasil:

Perhatikan bahwa deklarasi daftar termasuk kata kunci yang bisa berubah, mut . Gagal memasukkan kata kunci tersebut akan membuat daftar tidak dapat diubah, sehingga push_back metode tidak dapat digunakan dalam kasus itu:

Ini menunjukkan pemeriksa pinjaman sedang beraksi. Saat Anda memanggil metode di Rust, penerima, daftar dalam hal ini, harus dipinjam dengan implementasi metode itu sendiri! Saat Anda meminjam sesuatu yang mungkin ingin Anda modifikasi, Anda harus meminjamnya sebagai bisa berubah.

Inilah yang push_back implementasi tidak:


pub
fn push_back ( & mut diri , elt: T) {…}

Ini memungkinkan Rust mengetahui kapan suatu nilai dapat diubah oleh satu blok kode, yang membantu itu alasan tentang apa yang aman untuk diizinkan.

Katakanlah kita ingin menambahkan elemen ke daftar dalam fungsi terpisah:

gunakan std :: koleksi :: LinkedList;

fn utama () {

membiarkan mut daftar: LinkedList u64 > = LinkedList :: baru ();

add_elements_to ( & mut daftar);

println! ( “{:?}” , & daftar);

}

fn add_elements_to (daftar: & mut LinkedList u64 >) {

daftar. push_back ( 1 );

daftar. push_back ( 2 );

daftar. push_back ( 3 );

}

Hasil :

Itu masih berfungsi dan aman, jadi Rust mengizinkannya. Tapi misalkan kita ingin melakukan ini di utas terpisah … sekarang, itu tidak lagi aman, jadi Rust tidak akan membiarkan Anda melakukan itu:

menggunakan std :: koleksi :: LinkedList;

fn utama () {

membiarkan mut daftar: LinkedList u64 > = LinkedList :: baru ();

utas :: muncul ( pindah || {

tambahkan_elemen_ke ( & mut daftar);

}). Ikuti (). membuka ();

println! ( ” {:?} “ , & daftar ); // ERROR !!!!

}

fn add_elements_to (daftar: & mut LinkedList u64 >) {

daftar. push_back ( 1 );

daftar. push_back ( 2 );

daftar. push_back ( 3 );

}

Kesalahan:

-> src / main.rs: 12: 22
|
9 | utas :: bibit (pindah || {

| ------- nilai dipindahkan ke penutupan di sini
10 | add_elements_to (& mut list);
| ---- variabel dipindahkan karena digunakan dalam penutupan
11 |}). Join (). Unwrap ();
12 | println! ("{:?}", & list);
| ^^^^^ nilai yang dipinjam di sini setelah pindah
|
=catatan: pemindahan terjadi karena `list` memiliki tipe` std :: collections :: LinkedList `, yang tidak menerapkan sifat` Salin`

Pesan kesalahan di Rust sangat bagus dalam banyak kasus. Anda dapat dengan jelas melihat pengembang Rust berusaha keras untuk memperjelas bagi pengguna ketika terjadi kesalahan.

Dalam kasus kami di atas, ini dengan jelas memberi tahu kami bahwa kami tidak dapat menggunakan daftar setelah kami memindahkannya (perhatikan pindah kata kunci ketika kita mendeklarasikan penutupan Thread, yang secara implisit memindahkan variabel apa pun yang digunakan closure ke dalam cakupan baru) ke dalam cakupan closure karena tidak mengimplementasikan Salinan sifat (yang mungkin akan memungkinkannya). Dan kami harus memindahkannya karena tidak aman untuk mengizinkan dua utas berbeda memiliki akses ke variabel yang bisa berubah.

Rust memiliki solusi yang bagus untuk masalah ini: saluran .

Di bawah ini, kami menggunakan saluran untuk bisa mendapatkan kembali daftar tertaut kami setelah memberikannya ke utas lain:


menggunakan
std :: benang;

gunakan std :: koleksi :: LinkedList;

gunakan std :: sinkronisasi :: mpsc;

fn utama () {

membiarkan (tx, rx) = mpsc :: saluran ();

membiarkan mut daftar: LinkedList u64 > = LinkedList :: baru ();

utas :: muncul ( pindah || {

add_elements_to ( & mut li st);

tx. Kirim (daftar).membuka ();

}). Ikuti (). membuka ();

membiarkan daftar = rx. recv (). membuka () ;

println! ( “{:?}” , & daftar);

}

fn add_elements_to (daftar: & mut LinkedList u64 > ) {

daftar. push_back ( 1 );

daftar. push_back ( 2 );

daftar. push_back ( 3 );

}

Hasil:

Gaya menangani konkurensi dengan aman tanpa kunci ini disebut penyampaian pesan , dan merupakan salah satu konsep inti model Aktor, yang akan kita temui di bagian selanjutnya. Namun Rust juga memungkinkan Anda menggunakan alat konkurensi tingkat rendah.

Mari kita lihat contoh Rust Book memberikan tentang cara menggunakan Mutex untuk menangani status yang dapat berubah bersama (dalam hal ini, penghitung):


gunakan
std :: sinkronisasi :: {Mutex, Arc};

gunakan std :: benang;

fn utama () {

membiarkan penghitung = Busur : : baru (Mutex :: baru ( 0 ));

membiarkan mut pegangan = vec! [];

untuk _ di 0 .. 10 {

membiarkan penghitung = Busur :: klon ( & melawan);

membiarkan pegangan = utas :: muncul ( pindah || {

membiarkan mut num = penghitung. mengunci (). membuka ();

num += 1 ;

});

pegangan. Dorong (menangani);

}

untuk pegangan di pegangan {

pegangan. Ikuti (). membuka () ;

}

println! ( “Hasil: {}” , melawan.mengunci (). membuka ());

}

Hasil:

Contoh ini memulai 10 Untaian pada waktu yang sama (kurang-lebih) dan kemudian menunggu semuanya berjalan. Setiap Thread mengakses dan mengubah penghitung, yang pada akhirnya dicetak oleh utama fungsi. Alasan Rust mengizinkannya adalah bahwa sebelum setiap Thread mengakses penghitung, mereka mendapatkan kunci yang melindunginya dari akses bersamaan (kunci secara otomatis dilepaskan ketika Busur itu di luar ruang lingkup).

Busur adalah singkatan dari jumlah referensi atom dan merupakan rahasia untuk berbagi status antara beberapa utas di Rust.

Ini adalah konkurensi kuno yang bagus dengan kunci, tetapi dengan sedikit bantuan dari kompiler yang lebih cerdas!

Selanjutnya, kita akan melihat bahasa sistem lain, tetapi salah satu yang jaminan tidak adanya kebuntuan dan kondisi balapan.

Kemampuan Referensi (Pony)

Alternatif menarik lainnya sedang dipopulerkan oleh Bahasa pemrograman Pony : ini menggunakan sistem kemampuan untuk menunjukkan variabel mana yang aman untuk dibagikan antar utas (atau Aktor , yang mirip dengan benang hijau di Pony). Misalnya, jika variabel tidak dapat diubah (a val di Pony) maka Pony akan membiarkan Anda membagikannya Aktor lain tanpa batasan apa pun. Jika variabelnya adalah iso (untuk terisolasi), artinya hanya ada satu referensi untuk itu pada waktu tertentu ( yang diverifikasi oleh compiler), maka itu juga aman untuk membagikannya meskipun itu bisa berubah (karena tidak ada dua Aktor yang dapat menulis padanya pada saat yang sama). Dan ada beberapa kemampuan lain yang memberi Anda kendali yang baik untuk menentukan bagaimana variabel Anda harus digunakan, dengan kompilator memastikan aman untuk menggunakannya seperti itu.

Untuk mengetahui seperti apa bentuknya, mari kita menulis yang kecil Program kuda poni yang meneruskan variabel yang dapat berubah, katakanlah, sebuah Himpunan[U64], antara dua Aktor. Aktor pertama, Utama, membuat larik, mengirimkannya ke Adder aktor yang tahu cara menambahkan angka n ke setiap elemen larik, lalu mengirimkan hasilnya kembali ke Utama, yang mencetaknya.

aktor Utama

membiarkan _env: Env

biarkan _adder: Adder

baru membuat (env: Env )=>

_env=env

_adder= Adder (ini)

Mulailah ()

menjadi Mulailah ()=>

membiarkan my_array: Himpunan [U64] iso =[1; 2; 3; 4]

_adder. addN ( konsumsi my_array, 4 )

menjadi showResult (hasil: Himpunan [U64] iso )=>

membiarkan Sebuah: Himpunan [U64] kotak= konsumsi hasil

membiarkan len=a. si ze () – 1

membiarkan str=memulihkan Tali akhir

str. menambahkan ( “[“)

for (i, item) in a.pairs() do

str.append(item.string())

if i then

str.append(“, “)

end

end

str.append(“]” )

_env. di luar . mencetak ( konsumsi str)

aktor Adder

membiarkan _runner: Utama

baru membuat (pelari: Utama )=>

_runner=pelari

jadilah addN ( Himpunan: Himpunan [U64] iso , n: U64 )=>

membiarkan hasil= memulihkan iso

membiarkan Sebuah: Himpunan [U64] ref = konsumsi Himpunan

untuk (i, item) di Sebuah. pasangan () melakukan

mencoba Sebuah.memperbarui (i, item + n)? akhir

akhir

Sebuah

akhir

_runner. showResult ( konsumsi hasil)

Hasil:

Perhatikan bahwa array yang dimaksud selalu sama array, tidak ada salinan yang dibuat dari aslinya. Tetapi karena kami menggunakan Pony Actors, array pasti diteruskan di antara utas (dengan asumsi kedua aktor dijalankan di utas yang berbeda, yang mungkin tidak selalu terjadi), tetapi tidak seperti di kebanyakan bahasa, ini sepenuhnya aman (memori- dan konkurensi-aman) di Pony.

Untuk memahami alasannya, penjelasan yang lebih rinci harus diberikan.

Pertama-tama, perhatikan bahwa metode dari aktor dalam contoh di atas dinyatakan dengan menjadi kata kunci. Itu berarti mereka sebenarnya bukan metode dalam arti kata yang biasa (yang memang dimiliki Pony, mereka dideklarasikan dengan kesenangan ), mereka perilaku . Perilaku dijalankan secara asinkron (dan bersamaan dengan perilaku lain!). Oleh karena itu, argumen yang mereka ambil harus dapat dikirim , dan mereka tidak pernah mengembalikan nilai apa pun secara langsung.

Seperti yang kita lihat sebelumnya, Anda bisa mengirim yg mungkin berubah ke Aktor lain jika dan hanya jika itu iso variabel (juga memungkinkan untuk mengirim val karena tidak dapat diubah, dan tag yang pada dasarnya adalah Aktor lain). Itulah mengapa kami menyatakan addN (yang ingin mengubah array yang diberikan) seperti ini:


menjadi addN (Himpunan: Himpunan [U64] iso , n: U64 )=> …

Sebisa kamu lihat, kapabilitas dideklarasikan setelah tipe. Array [U64] iso berarti array integer 64-bit unsigned-64-bit dengan iso kemampuan. Saat memanggil perilaku ini, pemilik array harus menyerahkannya, yang dilakukan dengan konsumsi :


_adder.
addN ( konsumsi my_array, 4 )

n argumen adalah Primitif, jadi ini val dan selalu dapat diteruskan dengan aman ke Aktor lain. Dengan mengkonsumsi my_array , kami memastikan bahwa hanya ada satu referensi ke sana, dan referensi itu sekarang menjadi bagian dari badan addN perilaku.

Kuda Poni Primitif , yang menarik, berbeda dari bahasa lain karena dapat ditentukan oleh pengguna. Dikombinasikan dengan jenis serikat, mereka memungkinkan kode yang sangat efisien dan ringkas.

Melihat implementasinya dari addN , perhatikan baris berikut:


membiarkan Sebuah: Himpunan [U64] ref = konsumsi Himpunan

Ini mungkin terlihat tidak berguna, tetapi perlu karena Himpunan di sini memiliki iso , tetapi untuk memodifikasi internal array kita membutuhkan ref kemampuan (lihat pembaruan e ). The ref kemampuan memungkinkan kita membaca dan menulis ke variabel. Jadi, baris di atas pada dasarnya mentransmisikan iso ke ref . Ini aman selama kami tidak melewati ref sekitar, yang tidak diizinkan untuk kami lakukan oleh penyusun.

Akhirnya, menarik untuk diketahui bahwa pada akhir addN , kami harus meneruskan array kembali ke aktor Utama, tetapi kami tidak bisa lakukan itu karena array kita sekarang adalah ref (dan kami menggunakan yang asli Himpunan referensi, jadi tidak bisa digunakan lagi). Untuk memulihkan iso kemampuan yang memungkinkan kami melakukan itu, kami membungkus seluruh bagian kode tempat kami memutasi array di dalam memulihkan blokir.


membiarkan hasil= memulihkan iso

membiarkan Sebuah: Himpunan [U64] ref = konsumsi Himpunan

untuk (i, item ) saya n Sebuah. pasangan () melakukan

mencoba Sebuah.memperbarui (i, item + n)? akhir

akhir

Sebuah

akhir

_runner. showResult ( konsumsi hasil)

The Pony dokumen jelaskan ini secara lebih rinci, tetapi yang penting untuk dipahami adalah bahwa di dalam memulihkan blokir, hanya yang dapat dikirim nilai dari lingkup leksikal yang melampirkan dapat diakses, maka hasil dari blok tersebut adalah juga dapat dikirim . Jadi kami berakhir dengan yang dapat dikirim hasil!

Ini benar-benar brilian, tapi sayangnya, sejauh yang saya tahu, Pony belum mendapatkan banyak popularitas dalam beberapa tahun. sudah ada … Saya telah mengikuti perkembangannya untuk sementara waktu sebagai pengamat yang penasaran, dan agak sedih melihat bahwa, meskipun Pony membawa sesuatu yang cukup inovatif ke meja, sepertinya itu tidak cukup untuk menarik banyak orang untuk itu. Itu mungkin karena persaingan yang ketat, dengan Rust mengambil sebagian besar minat dari orang-orang yang bekerja pada kinerja tinggi, sistem yang sangat serentak. Tapi saya curiga itu karena kurangnya perkakas (saya bahkan menulis Plugin Gradle untuk itu dan mencoba berkontribusi ke plugin VS Code di beberapa titik tetapi bertemu dengan kekurangan total minat dari orang-orang yang menjalankan proyek, jadi menyerah) dan kurangnya komunitas yang lebih besar di sekitarnya, yang selalu menjadi masalah untuk bahasa baru.

Tapi saya masih memiliki harapan untuk Pony, karena saya pikir orang mungkin menyadari konkurensi yang terbukti aman (tidak ada jalan buntu, tidak ada kondisi balapan) dan kinerja tinggi (pengumpul sampah per aktor tingkat lanjut dengan jeda yang dapat diprediksi) pada saat yang sama merupakan masalah besar.

Itu Model Aktor (Erlang, Dart)

Solusi alternatif terakhir untuk menulis aplikasi bersamaan dengan aman yang saya ketahui adalah Model Aktor . Itu sudah ditemukan pada tahun 1970-an, sebagai sebagian besar ide terhebat dalam ilmu komputer, tetapi perlahan-lahan masuk ke arus utama.

Beberapa bahasa yang didasarkan pada Model Aktor termasuk Erlang dan bahasa kembarnya, Eliksir. Bahasa lain memiliki perpustakaan untuk mendukung Model Aktor, misalnya, Akka untuk Java / Scala, Pengendara untuk Rust, CAF untuk C ++. Bahkan ada perpustakaan lintas platform yang disebut Proto.Actor yang memungkinkan aktor Go, .Net dan Java / Kotlin untuk berkomunikasi satu sama lain.

Pony juga merupakan bagian dari kategori ini, tetapi karena caranya yang sangat berbeda pesan bekerja di Pony (misalnya, mereka bisa berubah) dan sistem kapabilitasnya yang unik, saya memutuskan untuk simpan di kategori khususnya sendiri.

Dalam Model Aktor, setiap Aktor memiliki statusnya sendiri, yang tidak dapat berinteraksi dengan Aktor lain kecuali melalui penyampaian pesan. Karenanya, konkurensi dicapai secara transparan oleh waktu proses, dengan kode aplikasi yang sebenarnya tidak perlu mengkhawatirkan hal itu.


modul ( tut15 ).

ekspor ([start/0, ping/2, pong/0]).

ping ( 0 , Pong_PID ) ->

Pong_PID ! selesai,

io : format ( “ping selesai ~ n” , []);

ping ( N , Pong_PID ) ->

Pong_PID ! {ping, diri ()},

menerima

pong ->

io : format ( “Ping menerima pong ~ n” , [])

akhir ,

ping ( N 1 , Pong_PID ).

pong () ->

menerima

selesai ->

io : format ( “Pong selesai ~ n” , []);

{ping, Ping_PID } ->

io : format ( “Pong menerima ping ~ n” , []),

Ping_PID ! pong,

pong ()

akhir.

Mulailah () ->

Pong_PID = muncul (tut15, pong, []) ,

muncul (tut15, ping, [3, Pong_PID]).

Hasil:

1> c (tut15).
{oke, tut15}

2> tut15: mulai (). 0,36 . 0 > Pong menerima ping Ping menerima pong Pong menerima ping Ping menerima pong Pong menerima ping Ping menerima pong ping selesai Pong selesai

Erlang adalah bahasa fungsional tradisional, sehingga seperti yang Anda lihat, aktor sebenarnya hanyalah fungsi yang menerima / mengirim pesan satu sama lain. Sintaksis untuk mengirim pesan ke aktor, yang proses panggilan Erlang (bukan proses OS, proses Erlang jauh lebih murah untuk dibuat dan ribuan dari mereka bisa ada pada waktu tertentu) adalah:

Proses PID dapat diperoleh dengan menelurkan aktor baru, yang dilakukan dengan menelepon muncul dengan modul dan fungsi yang akan dijalankan, serta pesan awal untuk memberikannya:


Pong_PID = muncul (tut15, pong, [])

Itu fungsi ping kemudian dapat mengirim pesan ke proses dengan PID ini:


Pong_PID ! {ping, diri ()}

Di sisi penerima, pencocokan pola digunakan untuk menangani pesan (karena Erlang diketik secara dinamis, pola cocok pada konten daripada jenis, tidak seperti Pony dan, seperti yang akan kita lihat, Dart) dalam menerima blok:


pong
() ->

menerima

selesai ->

io : format ( “Pong selesai ~ n” , []);

{ping, Ping_PID } ->

io : format ( “Pong menerima ping ~ n” , []),

Ping_PID ! pong,

pong ()

akhir .

Sangat rapi.

Berikut adalah contoh ping-pong yang kira-kira setara di Dart:


impor
‘panah: io’ menunjukkan keluar;

impor ‘dart: isolate’ ;

kelas PingStartMessage {

terakhir int n;

akhir SendPort pong;

PingStartMessage (ini . n, ini . pong);

}

ping ( PingStartMessage startMessage) {

var n = startMessage.n;

akhir pong = startMessage.pong;

terakhir pingPort = ReceivePort ();

pingPort. mendengarkan ((pesan) {

jika (pesan == ‘pong’ ) {

mencetak ( “Ping menerima pong” );

jika (n > 0 ) {

pong. Kirim (pingPort.sendPort);

n ;

} lain {

pong. Kirim ( ‘jadi’);

}

}

});

pong. Kirim (pingPort.sendPort);

}

pong ( KirimPort starterPort) {

terakhir pongPort = ReceivePort ();

pongPort. mendengarkan ((saya ssage) {

jika ( pesan == ‘jadi’ ) {

mencetak ( “Pong selesai” );

starterPort. Kirim(pesan);

} lain jika (pesan adalah SendPort ) {

mencetak ( “Pong menerima ping” );

pesan. Kirim ( ‘pong’ );

}

});

starterPort. Kirim (pongPort.sendPort);

}

utama () asinkron {

terakhir starterPort = ReceivePort ();

menunggu Memisahkan . muncul (pong, starterPort.sendPort);

starterPort. mendengarkan ((pesan) {

jika (pesan adalah SendPort ) {

// dapat port pong, berikan untuk ping

Memisahkan . muncul (ping , PingStartMessage ( 3 , pesan));

} lain jika (pesan == ‘jadi’ ) {

keluar ( 0 );

}

});

}

Hasil:

Pong menerima ping
Ping menerima pong
Pong menerima ping
Ping menerima pong
Pong menerima ping
Ping menerima pong
Pong menerima ping
Ping menerima pong
Pong selesai

Ini sedikit lebih bertele-tele daripada Erlang karena kami perlu melakukan lebih banyak pemeliharaan untuk memulai Isolat dan mengelola port mereka sendiri (lihat memisahkan, yang membantu dengan boilerplate). Tapi intinya sama.

Tanpa Isolat, Dart sebenarnya adalah bahasa single-threaded , bahkan mempertimbangkannya async / await. Fungsi asinkron hanya dijalankan dalam apa yang disebut Dart microtasks , yang merupakan event loop-nya, mirip dengan JavaScript.

Perhatikan bahwa semua kode Dart berjalan dalam Isolate, termasuk main. Tetapi jika kode aplikasi tidak pernah memulai Isolasi lain, seluruh aplikasi berjalan dalam satu Thread, pada satu CPU.

Mulai Dart 2, Isolasi tidak didukung dalam aplikasi web – mereka perlu menggunakan Pekerja Web sebagai gantinya. Namun, mereka bekerja dengan baik di Berdebar .

Namun, isolasi berjalan secara paralel dengan Isolasi lainnya. Mereka tidak berbagi memori dengan Isolat lain, oleh karena itu sangat mirip dengan proses Erlang.

Pada contoh di atas, kita dapat melihat bahwa beberapa jenis pesan sedang dipertukarkan. Misalnya, ketika ping isolate dimulai, kami memberinya PingStartMessage segera:


Memisahkan . muncul (ping, PingStartMessage ( 3 , pesan));

Pong Isolate menanggapi KirimPort pesan (jenis objek yang digunakan untuk mengirim pesan ke Isolat lain) dengan pong string:


jika (pesan adalah SendPort ) {

mencetak ( “Pong menerima ping” );

pesan. Kirim ( ‘pong’ );

}

Ketika ping Isolate menerimanya, itu mungkin mengirim ping lain atau jadi pesan:


jika (pesan == ‘pong’ ) {

mencetak ( “Ping menerima pong” );

jika (n > 0 ) {

pong. Kirim (pingPort.sendPort);

n ;

} lain {

pong. Kirim ( ‘jadi’ );

}

}

Semua pesan yang dikirim dalam contoh ini tidak dapat diubah, tetapi Dart sebenarnya tidak memerlukannya, yang bisa membingungkan.

Contoh ini menunjukkan bahwa:


impor
‘dart: isolate’ ;

impor ‘panah: io’ menunjukkan keluar;

kelas Pesan {

terakhir Daftar int > daftar;

akhir KirimPort pengirim;

Pesan (ini . l ist, ini.pengirim);

}

Pergilah ( Pesan pesan) {

daftar pesan. Menambahkan ( 10 );

cetak ( “Ditambahkan 10 ke daftar: $ { daftar pesan } “ );

pengirim pesan. Kirim ( ‘baik’);

}

utama () asinkron {

terakhir Pelabuhan = ReceivePort ();

akhir daftar = [1, 2, 3];

menunggu Memisahkan. muncul (Pergilah, Pesan (daftar, port.sendPort));

Pelabuhan.mendengarkan ((msg) {

jika (pesan == ‘baik’ ) {

mencetak ( ” Daftar setelah menerima pesan: $ daftar );

keluar ( 0 );

}

});

}

Hasil:

Menambahkan 10 ke daftar: [1, 2, 3, 10]
Daftar setelah menerima pesan: [1, 2, 3]

Alasannya sederhana: sekali lagi, tidak ada memori yang dibagi antara Dart Isolates. List the Isolate dalam contoh di atas yang dimodifikasi BUKAN daftar yang sama dengan yang dibuat oleh fungsi utama. Pesan disalin ketika mereka melewati batas Isolate. Untuk alasan itu, agar sesuatu seperti di atas berfungsi, Isolate perlu mengirim kembali hasil komputasinya. Jika tidak, tidak ada cara bagi Isolat untuk mengamati perubahan yang dibuat olehnya.

Dengan pengetahuan ini, kita dapat menulis ulang contoh di atas dengan benar sekarang:


impor
‘dart: isolate’ ;

impor ‘panah: io’ menunjukkan keluar;

kelas Pesan {

terakhir Daftar int > daftar;

akhir SendPort pengirim;

Pesan ( ini. daftar, ini .pengirim);

}

Pergilah ( Pesan pesan) {

daftar pesan. Menambahkan ( 10 );

cetak ( “Menambahkan 10 ke daftar: $ { daftar pesan } “ );

pengirim pesan. Kirim (daftar pesan);

}

utama () asinkron {

terakhir Pelabuhan = ReceivePort ();

akhir daftar = [1, 2, 3];

menunggu Memisahkan. muncul (Pergilah, Pesan (daftar, port.sendPort));

Pelabuhan.mendengarkan ((pesan ) {

jika (pesan adalah Daftar int > ) {

mencetak ( “Daftar setelah r eceiving pesan: $ pesan );

keluar ( 0 );

}

});

}

Hasil:

Menambahkan 10 ke daftar: [1, 2, 3, 10]
Daftar setelah menerima pesan: [1, 2, 3, 10]

Perhatikan bahwa artikel ini tidak menyertakan Go, bahasa yang edly juga memiliki solusi konkurensi yang elegan (
Saluran Go ) karena solusinya adalah sebenarnya tidak aman untuk thread – Tidak terlalu sulit untuk memiliki kondisi balapan di Go atau negara bagian yang rusak karena Go tidak memaksakan pemisahan antara yang dapat dibagikan dan tidak dapat dibagikan keadaan yang bisa berubah.

Untuk alasan yang sama, saya memutuskan untuk tidak memasukkan
Rutinitas Kotlin antara. Mereka pada dasarnya menderita masalah yang sama.

Harus jelas kepada pembaca bagaimana solusi yang disajikan di sini berbeda dari itu.

Saya juga perlu menyebutkan Haskell mungkin akan menjadi contoh yang lebih baik daripada Clojure dari bahasa fungsional yang mendukung konkurensi mudah karena peran sentral yang dimainkan oleh data yang tidak dapat diubah di dalamnya. Saya hanya memutuskan untuk menggunakan Clojure karena keakraban saya dengannya dan fakta bahwa tampaknya jauh lebih mudah untuk mengelola konkurensi di Clojure dari sudut pandang saya yang bias. Semoga penggemar Haskell di luar sana akan memaafkan saya :).

Ini adalah artikel panjang, tapi saya harap Anda telah mempelajari sesuatu yang baru tentang konkurensi yang akan berguna bagi Anda terlepas dari bahasa apa yang Anda gunakan.

Solusi berbeda yang dibahas dalam artikel ini tidak membentuk kategori yang bersih dan terpisah, tetapi saya yakin pengelompokan masuk akal. Yang penting bagi saya adalah mencoba menangkap esensi dari setiap model, dan menunjukkan bagaimana mereka membantu memastikan konkurensi dilakukan dengan aman.

Di Clojure, meskipun mutabilitas sebenarnya didukung melalui beberapa primitif tingkat rendah dan JVM itu sendiri ( semua kode Clojure dapat memanggil kode Java apa pun), selama pengguna tetap menggunakan koleksi yang tidak dapat diubah dalam kode bersamaan (yang sangat mudah dilakukan, dan bahkan idiomatis di Clojure) mereka akan sepenuhnya aman dari masalah konkurensi.

Ini benar bahwa mungkin saja bahasa apa pun menggunakan strategi ini (termasuk Java dan C ++), tetapi bahasa pemrograman fungsional secara umum cenderung jauh lebih baik dalam menangani data yang tidak dapat diubah – ini adalah titik terbaik mereka.

Rust, di sisi lain, dengan biaya yang membutuhkan penjelasan rinci oleh pemrogram tentang apa yang dapat diubah, dipinjam, dikirim ke utas lain, dll. bahkan dalam kode yang tidak dimaksudkan untuk digunakan secara bersamaan, memungkinkan pemrogram untuk mencapai tingkat kepercayaan yang tinggi dalam keamanan utas program mereka, dan dengan sangat sedikit biaya runtime.

Pony berada di tengah-tengah Rust dan Erlang. Ini memiliki kinerja yang sangat tinggi, tetapi umumnya terlihat dan terasa seperti bahasa tingkat yang jauh lebih tinggi. Ia memiliki sintaks yang seksi (terlihat seperti Python, tapi tidak, ia tidak menggunakan spasi yang bermakna secara sintaksis!) Sehingga menyulitkan saya untuk tidak ingin menulis semua kode saya di dalamnya! Tetapi kurangnya perpustakaan dan komunitas, serta perkakas, sangat mengganggu pengadopsiannya.

Erlang, bahasa tertua dan paling teruji dalam pertempuran dalam daftar, menawarkan VM yang sangat andal dan itu (atau Elixir) pasti harus dipertimbangkan oleh siapa pun yang ingin menulis sistem terdistribusi dengan ketersediaan tinggi yang tidak pasti membutuhkan kinerja logam telanjang yang sangat tinggi. Penggunaan pengetikan dinamisnya membuat saya sedikit ragu untuk menggunakannya, karena saya sangat menyukai bantuan yang diberikan oleh pengetikan statis.

Dart memiliki sistem konkuren yang sangat mudah didekati sehingga siapa pun yang terbiasa dengan Java, misalnya, dapat mempelajarinya dalam hitungan menit. Dukungan async / await luar biasa, dan Isolat cukup mudah untuk dikodekan.

Namun, penting untuk dipahami bahwa Dart memungkinkan Anda menulis kode yang tidak berperilaku dengan benar tanpa peringatan jika Anda tidak memahami keterbatasan Isolates (isolasi lengkap memori). Batasan ini bukanlah sesuatu yang dapat diangkat di masa depan dengan lebih banyak pekerjaan dari tim Dart: ini adalah fitur dasar Dart yang memungkinkannya, sebagai Erlang, untuk mendukung paralelisme nyata tanpa primitif konkurensi sama sekali.

Akhirnya saya Saya ingin menyebutkan bahwa tidak ada sistem terbaik, menurut saya. Untuk setiap kesempatan, salah satu sistem di atas bisa menjadi solusi terbaik. Mereka semua sangat baik, tetapi memiliki kekuatan dan kelemahan yang berbeda yang saya harap pembaca akan lebih siap untuk memahami setelah membaca artikel ini dan melakukan sedikit penelitian lebih lanjut berdasarkan informasi yang diberikan.

Read More

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments