Deadlock seharusnya menjadi masalah runtime. Itulah yang membuatnya sangat menjengkelkan. Kode Anda dikompilasi dengan bersih, pengujian Anda lulus, dan kemudian ia macet di produksi karena Proses A menunggu Proses B dan Proses B menunggu Proses A.

Tipe sesi membalikkan ini. Mereka mengenkode protocol komunikasi antar proses ke dalam sistem tipe itu sendiri. Kelas-kelas tertentu dari deadlock berhenti menjadi kejutan runtime dan mulai menjadi kesalahan compiler. Anda secara harfiah tidak dapat menulis kode yang mengalami deadlock.

Apa sebenarnya tipe sesi

Tipe sesi adalah disiplin tipe untuk saluran komunikasi. Alih-alih saluran menjadi pipeline yang tidak diberi tipe yang Anda baca dan tulis, saluran yang diberi tipe sesi membawa tipe yang menjelaskan urutan operasi yang diizinkan padanya secara tepat.

Kirim integer, lalu terima string, lalu tutup. Compiler melacak urutan ini di setiap langkah. Menyimpang darinya, dan Anda mendapat kesalahan tipe, bukan deadlock runtime.

Ide ini berasal dari kalkulus proses dan memiliki implementasi di Haskell, Scala, OCaml, dan Rust. Sistem kepemilikan Rust dan tipe afinitas membuatnya menjadi pilihan yang sangat alami, tetapi konsepnya tidak bergantung pada bahasa.

Bagaimana deadlock pengiriman pesan sebenarnya terjadi

Pertimbangkan dua proses yang perlu menukar data. Kesalahan umum terlihat seperti ini:

// Process A
tx1.send(data_a)?;
let result_a = rx2.recv()?;

// Process B
tx2.send(data_b)?;
let result_b = rx1.recv()?;

Kedua proses mencoba mengirim terlebih dahulu. Jika buffer saluran penuh, keduanya memblokir saat mengirim. Tidak satu pun yang pernah mencapai recv. Ini adalah deadlock ketidakcocokan komunikasi klasik.

Anda mungkin berpikir “jangan lakukan itu saja.” Tetapi dalam sistem nyata dengan lusinan saluran, logika kondisional, dan kode yang direfaktor enam bulan kemudian, pola ini menyelinap terus-menerus. Pemeriksa tipe tidak memiliki pendapat tentang apakah urutan kirim dan terima Anda koheren.

Bagaimana tipe sesi membuat deadlock tidak dapat direpresentasikan

Trik intinya adalah bahwa tipe sesi berubah setelah setiap operasi. Saluran tidak memiliki tipe statis. Ia memiliki tipe yang menjadi sesuatu yang lain setelah Anda menggunakannya.

Berikut adalah implementasi minimal dalam Rust yang mendemonstrasikan ide tersebut:

use std::marker::PhantomData;

struct Send<T, Next>(PhantomData<(T, Next)>);
struct Recv<T, Next>(PhantomData<(T, Next)>);
struct Close;

struct Chan<P>(PhantomData<P>);

impl<P> Chan<P> {
    fn new() -> Self {
        Chan(PhantomData)
    }
}

impl Chan<Close> {
    fn close(self) {}
}

impl<T, Next> Chan<Send<T, Next>> {
    fn send(self, value: T) -> Chan<Next> {
        drop(value);
        Chan(PhantomData)
    }
}

impl<T: Default, Next> Chan<Recv<T, Next>> {
    fn recv(self) -> (T, Chan<Next>) {
        (T::default(), Chan(PhantomData))
    }
}

Saluran dengan tipe Chan<Send<i32, Recv<String, Close>>> berarti: Anda harus mengirim i32, maka Anda akan memiliki saluran yang dapat menerima String, maka Anda akan memiliki saluran yang hanya dapat ditutup.

Metode send mengonsumsi saluran lama dan mengembalikan yang baru dengan tipe yang diperbarui. Karena sistem kepemilikan Rust memastikan self dikonsumsi, Anda tidak dapat menggunakan saluran lama lagi. Compiler tidak akan membiarkan Anda mengirim dua kali, atau menerima di luar urutan, atau lupa menutup.

Untuk skenario deadlock kami, Anda mendefinisikan kedua endpoint dengan tipe yang saling melengkapi:

type Client = Send<i32, Recv<String, Close>>;
type Server = Recv<i32, Send<String, Close>>;

fn client(ch: Chan<Client>) {
    let ch = ch.send(42);
    let (msg, ch) = ch.recv();
    println!("{}", msg);
    ch.close();
}

fn server(ch: Chan<Server>) {
    let (req, ch) = ch.recv();
    println!("{}", req);
    let ch = ch.send("hello".to_string());
    ch.close();
}

Proses A mengirim lalu menerima. Proses B menerima lalu mengirim. Tipe protocol menegakkan urutan ini.

Jika seseorang merefaktor Proses B untuk mengirim terlebih dahulu, compiler menolaknya segera:

// This will NOT compile:
fn bad_server(ch: Chan<Server>) {
    // error: no method named `send` found for struct `Chan<Recv<...>>`
    let ch = ch.send("hello".to_string());
}

Sistem tipe mengatakan saluran ini berada dalam keadaan menerima. Anda tidak dapat mengirim. Deadlock menjadi tidak mungkin untuk diekspresikan.

Di mana teori menjadi berantakan: percabangan dan rekursi

Protocol nyata bukanlah urutan linear. Mereka memiliki pilihan. Server mungkin menawarkan authenticate atau register. Tipe sesi menangani ini dengan tipe pilihan internal dan eksternal.

struct Credentials;
struct Token;
struct UserInfo;
struct Account;

enum AuthProtocol {
    Login(Send<Credentials, Recv<Token, Close>>),
    Register(Send<UserInfo, Recv<Account, Close>>),
}

Klien menawarkan pilihan, dan server memilih satu. Kedua endpoint harus setuju pada pilihan tersebut, atau tipenya tidak cocok.

Protocol rekursif, seperti koneksi persisten yang berputar kembali ke menu, memerlukan definisi tipe rekursif. Di sinilah sebagian besar bahasa kesulitan. Rust mendukung ini, tetapi menjadi verbose.

Ada juga masalah multipihak. Tipe sesi di atas bersifat biner: dua endpoint. Jika tiga proses atau lebih berkoordinasi, Anda memerlukan tipe sesi multipihak, yang secara signifikan lebih kompleks dan memiliki implementasi yang lebih sedikit.

Trade-off yang harus Anda ketahui

Tipe sesi menghilangkan kelas kesalahan, tetapi tidak menghilangkan semua deadlock. Deadlock global di mana setiap proses menunggu sumber daya eksternal, atau livelock di mana proses berputar tanpa kemajuan, masih mungkin terjadi. Tipe sesi secara khusus menargetkan ketidakcocokan komunikasi.

Mereka juga memperkenalkan overhead waktu kompilasi. Pesan kesalahan dari type stack yang dalam dapat tidak dapat dipahami. Kesalahan tipe saluran yang sederhana mungkin meluas menjadi 50 baris generic bersarang dalam output compiler. Ekosistem Rust telah meningkat di sini, tetapi debugging ketidakcocokan tipe sesi masih merupakan keterampilan yang diperoleh.

Topologi dinamis adalah titik sakit lainnya. Tipe sesi berfungsi paling baik ketika grafik komunikasi bersifat statis dan diketahui pada waktu kompilasi. Jika Anda memunculkan saluran berdasarkan data runtime, seperti ruang obrolan dengan jumlah peserta yang bervariasi, tipe sesi menjadi jauh lebih sulit diterapkan.

Cara mencoba ini hari ini

Jika Anda ingin bereksperimen dengan tipe sesi di Rust, crate session_types menyediakan implementasi matang berdasarkan teori asli. Untuk API yang lebih ergonomis, sesh menawarkan pendekatan alternatif.

Di Haskell, session-types memberi Anda jaminan serupa dengan pemrograman tingkat tipe Haskell. Untuk sesuatu yang lebih dekat dengan penggunaan industri, lihat Protocol Buffers dengan stub klien yang dihasilkan. Meskipun bukan tipe sesi dalam pengertian formal, kode yang dihasilkan menegakkan prinsip yang sama: protocol ditentukan secara eksternal, dan compiler memeriksa penggunaan Anda terhadapnya.

Jika Anda membangun sistem terdistribusi dengan seperangkat proses komunikasi yang tetap, mulailah dengan menggambar grafik komunikasi. Gambar panah untuk setiap pesan. Jika grafiknya cukup kompleks sehingga Anda khawatir akan ketidakcocokan, di situlah tipe sesi bermanfaat.

FAQ

Apakah tipe sesi mencegah semua deadlock?

Tidak. Mereka mencegah deadlock yang disebabkan oleh ketidakcocokan komunikasi, seperti dua proses yang sama-sama menunggu untuk mengirim. Mereka tidak mencegah deadlock sumber daya, livelock, atau deadlock yang melibatkan sistem eksternal.

Apa perbedaan antara tipe sesi dan state machine?

Tipe sesi adalah state machine yang dienkode dalam sistem tipe. Transisi status ditegakkan oleh compiler pada setiap operasi saluran, bukan diperiksa saat runtime.

Apakah tipe sesi digunakan di produksi?

Tipe sesi biner digunakan dalam sistem penelitian dan domain khusus. Tipe sesi multipihak masih sebagian besar bersifat akademis. Konsep-konsep tersebut mempengaruhi desain API modern, termasuk klien gRPC yang dihasilkan dan pola type-state Rust.

Bisakah saya menggunakan tipe sesi tanpa Rust?

Ya. Haskell, Scala, dan OCaml semuanya memiliki pustaka tipe sesi. Bahkan dalam bahasa tanpa tipe afinitas, Anda dapat mendekati pola tersebut dengan pemeriksa tipe linear atau pernyataan runtime.

Deadlock sekarang menjadi masalah tipe

Deadlock adalah masalah runtime sampai Anda menjadikannya masalah tipe. Tipe sesi bukanlah peluru perak, tetapi untuk sistem pengiriman pesan dengan protocol yang terdefinisi dengan baik, mereka memindahkan seluruh kelas bug dari “tangkap di produksi” ke “tangkap saat kompilasi.” Itu adalah trade-off yang layak dipertimbangkan lain kali Anda sedang membuat sketsa arsitektur layanan baru.