Konkurensi dan Toleransi Kesalahan Menjadi Mudah: Tutorial Akka Dengan Contoh
Diterbitkan: 2022-03-11Tantangan
Menulis program bersamaan itu sulit. Harus berurusan dengan utas, kunci, kondisi balapan, dan sebagainya sangat rawan kesalahan dan dapat menyebabkan kode yang sulit dibaca, diuji, dan dipelihara.
Oleh karena itu, banyak yang lebih memilih untuk menghindari multithreading sama sekali. Sebaliknya, mereka menggunakan proses single-threaded secara eksklusif, mengandalkan layanan eksternal (seperti database, antrian, dll.) untuk menangani operasi konkuren atau asinkron yang diperlukan. Sementara pendekatan ini dalam beberapa kasus merupakan alternatif yang sah, ada banyak skenario di mana itu bukan pilihan yang layak. Banyak sistem waktu-nyata – seperti aplikasi perdagangan atau perbankan, atau permainan waktu-nyata – tidak memiliki kemewahan menunggu proses utas tunggal untuk diselesaikan (mereka membutuhkan jawabannya sekarang!). Sistem lain sangat membutuhkan komputasi atau sumber daya sehingga membutuhkan banyak waktu (jam atau bahkan berhari-hari dalam beberapa kasus) untuk berjalan tanpa memasukkan paralelisasi ke dalam kode mereka.
Salah satu pendekatan utas tunggal yang cukup umum (banyak digunakan di dunia Node.js, misalnya) adalah dengan menggunakan paradigma non-pemblokiran berbasis peristiwa. Meskipun hal ini membantu kinerja dengan menghindari sakelar konteks, penguncian, dan pemblokiran, ini tetap tidak mengatasi masalah penggunaan beberapa prosesor secara bersamaan (melakukannya akan memerlukan peluncuran, dan koordinasi antara, beberapa proses independen).
Jadi, apakah ini berarti Anda tidak punya pilihan selain melakukan perjalanan jauh ke dalam utas, kunci, dan kondisi balapan untuk membangun aplikasi bersamaan?
Berkat kerangka kerja Akka, jawabannya adalah tidak. Tutorial ini memperkenalkan contoh Akka dan mengeksplorasi cara memfasilitasi dan menyederhanakan implementasi aplikasi terdistribusi secara bersamaan.
Apa itu Kerangka Akka?
Akka adalah toolkit dan runtime untuk membangun aplikasi yang sangat konkuren, terdistribusi, dan toleran terhadap kesalahan pada JVM. Akka ditulis dalam Scala, dengan ikatan bahasa yang disediakan untuk Scala dan Java.
Pendekatan Akka untuk menangani konkurensi didasarkan pada Model Aktor. Dalam sistem berbasis aktor, semuanya adalah aktor, dengan cara yang hampir sama bahwa segala sesuatu adalah objek dalam desain berorientasi objek. Perbedaan utama, meskipun - sangat relevan dengan diskusi kita - adalah bahwa Model Aktor secara khusus dirancang dan dirancang untuk berfungsi sebagai model bersamaan sedangkan model berorientasi objek tidak. Lebih khusus lagi, dalam sistem aktor Scala, aktor berinteraksi dan berbagi informasi, tanpa pengandaian urutan. Mekanisme di mana para aktor berbagi informasi satu sama lain, dan saling tugas, adalah penyampaian pesan.
Akka menciptakan lapisan antara aktor dan sistem yang mendasarinya sehingga aktor hanya perlu memproses pesan. Semua kerumitan pembuatan dan penjadwalan utas, penerimaan dan pengiriman pesan, dan penanganan kondisi balapan dan sinkronisasi, diturunkan ke kerangka kerja untuk ditangani secara transparan.
Akka secara ketat mematuhi The Reactive Manifesto. Aplikasi reaktif bertujuan untuk menggantikan aplikasi multithread tradisional dengan arsitektur yang memenuhi satu atau lebih persyaratan berikut:
- Didorong oleh peristiwa. Menggunakan Aktor, seseorang dapat menulis kode yang menangani permintaan secara asinkron dan menggunakan operasi non-pemblokiran secara eksklusif.
- Dapat diskalakan. Di Akka, menambahkan node tanpa harus mengubah kode dimungkinkan, berkat penyampaian pesan dan transparansi lokasi.
- Ulet. Aplikasi apa pun akan mengalami kesalahan dan gagal di beberapa titik waktu. Akka menyediakan strategi “pengawasan” (toleransi kesalahan) untuk memfasilitasi sistem penyembuhan diri.
- Responsif. Banyak aplikasi kinerja tinggi dan respons cepat saat ini perlu memberikan umpan balik yang cepat kepada pengguna dan oleh karena itu perlu bereaksi terhadap peristiwa dengan cara yang sangat tepat waktu. Strategi non-pemblokiran berbasis pesan Akka membantu mencapai hal ini.
Apa itu Aktor di Akka?
Seorang aktor pada dasarnya tidak lebih dari sebuah objek yang menerima pesan dan mengambil tindakan untuk menanganinya. Itu dipisahkan dari sumber pesan dan satu-satunya tanggung jawab adalah mengenali dengan benar jenis pesan yang diterimanya dan mengambil tindakan yang sesuai.
Setelah menerima pesan, aktor dapat mengambil satu atau lebih tindakan berikut:
- Jalankan beberapa operasi itu sendiri (seperti melakukan perhitungan, menyimpan data, memanggil layanan web eksternal, dan sebagainya)
- Meneruskan pesan, atau pesan turunan, ke aktor lain
- Buat instance aktor baru dan teruskan pesannya
Atau, aktor dapat memilih untuk mengabaikan pesan sepenuhnya (yaitu, mungkin memilih tidak bertindak) jika dianggap tepat untuk melakukannya.
Untuk mengimplementasikan seorang aktor, perlu untuk memperluas sifat akka.actor.Actor dan menerapkan metode penerima. Metode penerima aktor dipanggil (oleh Akka) saat pesan dikirim ke aktor itu. Implementasi tipikalnya terdiri dari pencocokan pola, seperti yang ditunjukkan pada contoh Akka berikut, untuk mengidentifikasi jenis pesan dan bereaksi sesuai dengan itu:
import akka.actor.Actor import akka.actor.Props import akka.event.Logging class MyActor extends Actor { def receive = { case value: String => doSomething(value) case _ => println("received unknown message") } }
Pencocokan pola adalah teknik yang relatif elegan untuk menangani pesan, yang cenderung menghasilkan kode yang "lebih bersih" dan lebih mudah dinavigasi daripada implementasi yang sebanding berdasarkan panggilan balik. Pertimbangkan, misalnya, implementasi permintaan/respons HTTP yang sederhana.
Pertama, mari kita terapkan ini menggunakan paradigma berbasis panggilan balik dalam JavaScript:
route(url, function(request){ var query = buildQuery(request); dbCall(query, function(dbResponse){ var wsRequest = buildWebServiceRequest(dbResponse); wsCall(wsRequest, function(wsResponse) { sendReply(wsResponse); }); }); });
Sekarang mari kita bandingkan ini dengan implementasi berbasis pencocokan pola:
msg match { case HttpRequest(request) => { val query = buildQuery(request) dbCall(query) } case DbResponse(dbResponse) => { var wsRequest = buildWebServiceRequest(dbResponse); wsCall(dbResponse) } case WsResponse(wsResponse) => sendReply(wsResponse) }
Meskipun kode JavaScript berbasis panggilan balik memang ringkas, tentu saja lebih sulit untuk dibaca dan dinavigasi. Sebagai perbandingan, kode berbasis pencocokan pola membuat lebih jelas kasus apa yang sedang dipertimbangkan dan bagaimana masing-masing ditangani.
Sistem Aktor
Mengambil masalah yang kompleks dan membaginya secara rekursif menjadi sub-masalah yang lebih kecil adalah teknik pemecahan masalah yang baik secara umum. Pendekatan ini dapat sangat bermanfaat dalam ilmu komputer (konsisten dengan Prinsip Tanggung Jawab Tunggal), karena cenderung menghasilkan kode yang bersih dan termodulasi, dengan sedikit atau tanpa redundansi, yang relatif mudah dipelihara.
Dalam desain berbasis aktor, penggunaan teknik ini memfasilitasi organisasi logis aktor ke dalam struktur hierarkis yang dikenal sebagai Sistem Aktor. Sistem aktor menyediakan infrastruktur di mana aktor berinteraksi satu sama lain.
Di Akka, satu-satunya cara untuk berkomunikasi dengan aktor adalah melalui ActorRef
. ActorRef
mewakili referensi ke aktor yang menghalangi objek lain untuk mengakses atau memanipulasi internal dan status aktor itu secara langsung. Pesan dapat dikirim ke aktor melalui ActorRef
menggunakan salah satu protokol sintaks berikut:
-
!
(“beri tahu”) – mengirim pesan dan segera kembali -
?
(“ask”) – mengirim pesan dan mengembalikan Future yang mewakili kemungkinan balasan
Setiap aktor memiliki kotak surat yang pesan masuknya dikirim. Ada beberapa implementasi kotak surat yang dapat dipilih, dengan implementasi default adalah FIFO.
Sebuah aktor berisi banyak variabel instan untuk mempertahankan status saat memproses banyak pesan. Akka memastikan bahwa setiap instance aktor berjalan di thread ringannya sendiri dan pesan diproses satu per satu. Dengan cara ini, setiap status aktor dapat dipertahankan dengan andal tanpa perlu khawatir secara eksplisit tentang sinkronisasi atau kondisi balapan.
Setiap aktor diberikan informasi berguna berikut untuk melakukan tugasnya melalui Akka Actor API:
-
sender
: seorangActorRef
ke pengirim pesan yang sedang diproses -
context
: informasi dan metode yang berkaitan dengan konteks di mana aktor sedang berjalan (termasuk, misalnya, metodeactorOf
untuk membuat instance aktor baru) -
supervisionStrategy
: mendefinisikan strategi yang akan digunakan untuk memulihkan dari kesalahan -
self
:ActorRef
untuk aktor itu sendiri
Untuk membantu menyatukan tutorial ini, mari pertimbangkan contoh sederhana menghitung jumlah kata dalam file teks.
Untuk tujuan contoh Akka kami, kami akan menguraikan masalah menjadi dua subtugas; yaitu, (1) tugas “anak” untuk menghitung jumlah kata pada satu baris dan (2) tugas “induk” untuk menjumlahkan jumlah kata per baris untuk mendapatkan jumlah total kata dalam file.
Aktor induk akan memuat setiap baris dari file dan kemudian mendelegasikan kepada aktor anak tugas menghitung kata-kata di baris itu. Ketika anak selesai, itu akan mengirim pesan kembali ke orang tua dengan hasilnya. Induk akan menerima pesan dengan jumlah kata (untuk setiap baris) dan menyimpan penghitung untuk jumlah total kata di seluruh file, yang kemudian akan dikembalikan ke pemanggilnya setelah selesai.
(Perhatikan bahwa contoh kode tutorial Akka yang disediakan di bawah ini dimaksudkan hanya untuk didaktik dan oleh karena itu tidak selalu berkaitan dengan semua kondisi tepi, pengoptimalan kinerja, dan sebagainya. Juga, versi lengkap yang dapat dikompilasi dari contoh kode yang ditunjukkan di bawah ini tersedia di inti ini.)
Pertama-tama mari kita lihat contoh implementasi dari kelas Anak StringCounterActor
:
case class ProcessStringMsg(string: String) case class StringProcessedMsg(words: Integer) class StringCounterActor extends Actor { def receive = { case ProcessStringMsg(string) => { val wordsInLine = string.split(" ").length sender ! StringProcessedMsg(wordsInLine) } case _ => println("Error: message not recognized") } }
Aktor ini memiliki tugas yang sangat sederhana: mengkonsumsi pesan ProcessStringMsg
(berisi sebaris teks), menghitung jumlah kata pada baris yang ditentukan, dan mengembalikan hasilnya ke pengirim melalui pesan StringProcessedMsg
. Perhatikan bahwa kami telah mengimplementasikan kelas kami untuk menggunakan !
("beri tahu") metode untuk mengirim pesan StringProcessedMsg
(yaitu, untuk mengirim pesan dan segera kembali).

Oke, sekarang mari kita alihkan perhatian kita ke kelas induk WordCounterActor
:
1. case class StartProcessFileMsg() 2. 3. class WordCounterActor(filename: String) extends Actor { 4. 5. private var running = false 6. private var totalLines = 0 7. private var linesProcessed = 0 8. private var result = 0 9. private var fileSender: Option[ActorRef] = None 10. 11. def receive = { 12. case StartProcessFileMsg() => { 13. if (running) { 14. // println just used for example purposes; 15. // Akka logger should be used instead 16. println("Warning: duplicate start message received") 17. } else { 18. running = true 19. fileSender = Some(sender) // save reference to process invoker 20. import scala.io.Source._ 21. fromFile(filename).getLines.foreach { line => 22. context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line) 23. totalLines += 1 24. } 25. } 26. } 27. case StringProcessedMsg(words) => { 28. result += words 29. linesProcessed += 1 30. if (linesProcessed == totalLines) { 31. fileSender.map(_ ! result) // provide result to process invoker 32. } 33. } 34. case _ => println("message not recognized!") 35. } 36. }
Banyak hal yang terjadi di sini, jadi mari kita periksa masing-masing secara lebih rinci (perhatikan bahwa nomor baris yang dirujuk dalam diskusi berikut didasarkan pada contoh kode di atas) …
Pertama, perhatikan bahwa nama file yang akan diproses diteruskan ke konstruktor WordCounterActor
(baris 3). Hal ini menunjukkan bahwa aktor hanya akan digunakan untuk memproses satu file. Ini juga menyederhanakan pekerjaan pengkodean untuk pengembang, dengan menghindari kebutuhan untuk mengatur ulang variabel status ( running
, totalLines
, linesProcessed
, dan result
) ketika pekerjaan selesai, karena instance hanya digunakan sekali (yaitu, untuk memproses satu file) dan kemudian dibuang.
Selanjutnya, perhatikan bahwa WordCounterActor
menangani dua jenis pesan:
-
StartProcessFileMsg
(baris 12)- Diterima dari aktor eksternal yang awalnya memulai
WordCounterActor
. - Ketika diterima,
WordCounterActor
pertama-tama memeriksa bahwa itu tidak menerima permintaan yang berlebihan. - Jika permintaan berlebihan,
WordCounterActor
membuat peringatan dan tidak ada lagi yang dilakukan (baris 16). - Jika permintaan tidak berlebihan:
-
WordCounterActor
menyimpan referensi ke pengirim dalam variabel instansfileSender
(perhatikan bahwa ini adalahOption[ActorRef]
daripadaOption[Actor]
- lihat baris 9).ActorRef
ini diperlukan untuk mengakses dan meresponsnya nanti saat memprosesStringProcessedMsg
akhir (yang diterima dari turunanStringCounterActor
, seperti yang dijelaskan di bawah). -
WordCounterActor
kemudian membaca file dan, saat setiap baris dalam file dimuat,StringCounterActor
dibuat dan pesan berisi baris yang akan diproses diteruskan ke sana (baris 21-24).
-
- Diterima dari aktor eksternal yang awalnya memulai
-
StringProcessedMsg
(baris 27)- Diterima dari anak
StringCounterActor
saat selesai memproses baris yang ditetapkan untuknya. - Ketika diterima,
WordCounterActor
menambah penghitung baris untuk file dan, jika semua baris dalam file telah diproses (yaitu, ketikatotalLines
danlinesProcessed
sama), ia mengirimkan hasil akhir kefileSender
asli (baris 28-31).
- Diterima dari anak
Sekali lagi, perhatikan bahwa di Akka, satu-satunya mekanisme untuk komunikasi antar aktor adalah penyampaian pesan. Pesan adalah satu-satunya hal yang dibagikan oleh aktor dan, karena aktor berpotensi mengakses pesan yang sama secara bersamaan, penting bagi mereka untuk tidak berubah, untuk menghindari kondisi ras dan perilaku yang tidak terduga.
Oleh karena itu, biasanya untuk menyampaikan pesan dalam bentuk kelas kasus karena pesan tersebut tidak dapat diubah secara default dan karena integrasinya yang mulus dengan pencocokan pola.
Mari kita simpulkan contoh dengan contoh kode untuk menjalankan seluruh aplikasi.
object Sample extends App { import akka.util.Timeout import scala.concurrent.duration._ import akka.pattern.ask import akka.dispatch.ExecutionContexts._ implicit val ec = global override def main(args: Array[String]) { val system = ActorSystem("System") val actor = system.actorOf(Props(new WordCounterActor(args(0)))) implicit val timeout = Timeout(25 seconds) val future = actor ? StartProcessFileMsg() future.map { result => println("Total number of words " + result) system.shutdown } } }
Perhatikan bagaimana kali ini ?
metode yang digunakan untuk mengirim pesan. Dengan cara ini, pemanggil dapat menggunakan Future yang dikembalikan untuk mencetak hasil akhir saat ini tersedia dan untuk keluar dari program dengan mematikan ActorSystem.
Toleransi kesalahan Akka dan strategi supervisor
Dalam sistem aktor, setiap aktor adalah pengawas anak-anaknya. Jika seorang aktor gagal menangani sebuah pesan, ia akan menangguhkan dirinya sendiri dan semua anaknya dan mengirim pesan, biasanya dalam bentuk pengecualian, kepada supervisornya.
Di Akka, cara seorang supervisor bereaksi dan menangani pengecualian yang merembes ke sana dari anak-anaknya disebut sebagai strategi supervisor. Strategi supervisor adalah mekanisme utama dan langsung yang digunakan untuk menentukan perilaku toleransi kesalahan sistem Anda.
Saat pesan yang menandakan kegagalan mencapai supervisor, ia dapat mengambil salah satu tindakan berikut:
- Lanjutkan anak (dan anak-anaknya), pertahankan keadaan internalnya. Strategi ini dapat diterapkan ketika keadaan anak tidak rusak oleh kesalahan dan dapat terus berfungsi dengan benar.
- Mulai ulang anak (dan anak-anaknya), bersihkan keadaan internalnya. Strategi ini dapat digunakan dalam skenario kebalikan dari yang baru saja dijelaskan. Jika status anak telah rusak oleh kesalahan, perlu mengatur ulang statusnya sebelum dapat digunakan di Masa Depan.
- Hentikan anak (dan anak-anaknya) secara permanen. Strategi ini dapat digunakan dalam kasus di mana kondisi kesalahan tidak diyakini dapat diperbaiki, tetapi tidak membahayakan sisa operasi yang dilakukan, yang dapat diselesaikan tanpa adanya anak yang gagal.
- Hentikan sendiri dan tingkatkan kesalahannya. Dipekerjakan ketika penyelia tidak tahu bagaimana menangani kegagalan dan karenanya meningkatkannya ke penyelianya sendiri.
Selain itu, Aktor dapat memutuskan untuk menerapkan tindakan hanya untuk anak-anak gagal atau saudara kandungnya juga. Ada dua strategi yang telah ditentukan sebelumnya untuk ini:
-
OneForOneStrategy
: Menerapkan tindakan yang ditentukan hanya untuk anak yang gagal -
AllForOneStrategy
: Menerapkan tindakan yang ditentukan ke semua anaknya
Berikut adalah contoh sederhana, menggunakan OneForOneStrategy
:
import akka.actor.OneForOneStrategy import akka.actor.SupervisorStrategy._ import scala.concurrent.duration._ override val supervisorStrategy = OneForOneStrategy() { case _: ArithmeticException => Resume case _: NullPointerException => Restart case _: IllegalArgumentException => Stop case _: Exception => Escalate }
Jika tidak ada strategi yang ditentukan, strategi default berikut digunakan:
- Jika ada kesalahan saat menginisialisasi aktor atau jika aktor terbunuh, aktor dihentikan.
- Jika ada jenis pengecualian lain, aktor hanya memulai ulang.
Implementasi strategi default yang disediakan Akka ini adalah sebagai berikut:
final val defaultStrategy: SupervisorStrategy = { def defaultDecider: Decider = { case _: ActorInitializationException ⇒ Stop case _: ActorKilledException ⇒ Stop case _: Exception ⇒ Restart } OneForOneStrategy()(defaultDecider) }
Akka mengizinkan penerapan strategi pengawas kustom, tetapi seperti yang diperingatkan oleh dokumentasi Akka, lakukan dengan hati-hati karena implementasi yang salah dapat menyebabkan masalah seperti sistem aktor yang diblokir (yaitu aktor yang ditangguhkan secara permanen).
Transparansi lokasi
Arsitektur Akka mendukung transparansi lokasi, memungkinkan aktor untuk sepenuhnya agnostik ke tempat asal pesan yang mereka terima. Pengirim pesan mungkin berada di JVM yang sama dengan aktor atau di JVM terpisah (berjalan pada node yang sama atau node yang berbeda). Akka memungkinkan setiap kasus ini ditangani dengan cara yang benar-benar transparan bagi aktor (dan oleh karena itu pengembang). Satu-satunya peringatan adalah bahwa pesan yang dikirim melalui beberapa node harus serial.
Sistem aktor dirancang untuk berjalan di lingkungan terdistribusi tanpa memerlukan kode khusus. Akka hanya membutuhkan keberadaan file konfigurasi ( application.conf
) yang menentukan node untuk mengirim pesan. Berikut adalah contoh sederhana dari file konfigurasi:
akka { actor { provider = "akka.remote.RemoteActorRefProvider" } remote { transport = "akka.remote.netty.NettyRemoteTransport" netty { hostname = "127.0.0.1" port = 2552 } } }
Beberapa tips perpisahan…
Kita telah melihat bagaimana kerangka kerja Akka membantu mencapai konkurensi dan kinerja tinggi. Namun, seperti yang ditunjukkan oleh tutorial ini, ada beberapa poin yang perlu diingat saat merancang dan mengimplementasikan sistem Anda untuk memanfaatkan kekuatan Akka secara maksimal:
- Sedapat mungkin, setiap aktor harus diberi tugas sekecil mungkin (seperti yang telah dibahas sebelumnya, mengikuti Prinsip Tanggung Jawab Tunggal)
Aktor harus menangani peristiwa (yaitu, memproses pesan) secara asinkron dan tidak boleh memblokir, jika tidak, sakelar konteks akan terjadi yang dapat berdampak buruk pada kinerja. Secara khusus, yang terbaik adalah melakukan operasi pemblokiran (IO, dll.) di Masa Depan agar tidak memblokir aktor; yaitu:
case evt => blockingCall() // BAD case evt => Future { blockingCall() // GOOD }
- Pastikan semua pesan Anda tidak dapat diubah, karena aktor yang meneruskannya satu sama lain semuanya akan berjalan secara bersamaan di utas mereka sendiri. Pesan yang dapat diubah cenderung menghasilkan perilaku yang tidak terduga.
- Karena pesan yang dikirim antar node harus serial, penting untuk diingat bahwa semakin besar pesan, semakin lama waktu yang dibutuhkan untuk membuat serial, mengirim, dan deserialize mereka, yang dapat berdampak negatif pada kinerja.
Kesimpulan
Akka, yang ditulis dalam Scala, menyederhanakan dan memfasilitasi pengembangan aplikasi yang sangat konkuren, terdistribusi, dan toleran terhadap kesalahan, menyembunyikan banyak kerumitan dari pengembang. Melakukan Akka penuh keadilan akan membutuhkan lebih dari satu tutorial ini, tapi mudah-mudahan pengantar ini dan contoh-contohnya cukup menawan untuk membuat Anda ingin membaca lebih lanjut.
Amazon, VMWare, dan CSC hanyalah beberapa contoh perusahaan terkemuka yang aktif menggunakan Akka. Kunjungi situs web resmi Akka untuk mempelajari lebih lanjut dan mengeksplorasi apakah Akka bisa menjadi jawaban yang tepat untuk proyek Anda juga.