Rill'de Kanallar ve Strand'ler: Eşzamanlılığa Derinlemesine Bakış
Buluşmalı ve tamponlu kanallar, close ile yayın, boru hatları, eşzamanlı asal eleği, select ile yoklama ve son tarih, sinyalle zarif kapanma, SDL tarzı durum makineleri, bir milyon strand ve --parallel paylaşım denetimi. Tüm örnekler çalıştırıldı, çıktılar gerçek.

Önceki yazıda Rill'in genel bir turunu atmıştık. Bu yazıda tek bir konuya, dilin bence en keyifli tarafına odaklanıyoruz: strand'ler ve kanallar. Buluşmalı kanalın tam olarak ne garanti ettiğinden tamponlu kanallara, close ile yayın yapmaktan boru hatlarına, eşzamanlı asal eleğinden select ile zaman aşımına, sinyalle zarif kapanmadan SDL tarzı durum makinelerine ve çok çekirdekte paylaşım denetimine kadar gideceğiz.
Yazıdaki her programı yazmadan önce derleyip çalıştırdım; çıktı blokları o çalıştırmaların gerçek çıktısı. Ölçümler Apple M4 üzerinde, tek işçiyle (aksi belirtilmedikçe) alındı.
Strand nedir?
Strand, Rill'in hafif iş parçacığı. Go'da goroutine, Erlang'da process, Java'da sanal iş parçacığı denen kavramın aynısı; isim ise bir halatın bükümlerinden geliyor: bir program, birbirine sarılmış birçok strand'den oluşuyor. (C++ ağ programcıları için not: Boost.Asio'daki strand tamamen başka bir şey, sıralı bir yürütme bağlamı. İkisinin ilgisi yok.)
Bir strand başlatmak için tek bir anahtar kelime yeterli:
spawn worker(jobs, results)
spawn ifadenin tamamını yeni strand'de çalıştırır. Bu küçük ayrıntı, sunucu yazarken düşülen klasik bir tuzağı açıklıyor. spawn handle(accept(server)) yazarsanız accept de yeni strand'de çalışır. Ana döngü hiç beklemez; her turda accept'te bekleyen yeni bir strand doğurur ve boşa döner. Doğrusu önce bağlamak, sonra strand'e vermek:
fn serve(server) =
conn = tcp_accept(server)
spawn handle(conn)
serve(server)
Burada döngünün kendisi bekliyor ve bağlantıyı strand'e devrediyor. serve kendini kuyruk konumunda çağırdığı için yığıt da büyümüyor.
Strand'ler ucuz, çünkü Rill "bloklanınca kopyala" (copy-on-block) yığıt modelini kullanıyor. Çalışan strand işçinin yığıtında çalışıyor; park edildiğinde kullandığı yığıt parçası kopyalanıp saklanıyor. Rill deposundaki ölçümlerde park edilmiş bir strand yaklaşık 300 bayt tutuyor.
Buluşmalı kanal: bir el sıkışma
channel() tamponu olmayan bir kanal yaratır. Böyle bir kanalda gönderen, bir alıcı gelene kadar bekler; alıcı da bir gönderen gelene kadar bekler. Değer ancak ikisi buluştuğunda el değiştirir. Bunu görmek için iki strand arasında üç mesaj gönderelim ve her adımı yazdıralım:
fn ping(ch, n) =
if n == 0 then ()
else
println("ping: " + int_to_str(n) + " gönderiliyor")
send(ch, n)
println("ping: " + int_to_str(n) + " teslim edildi")
ping(ch, n - 1)
fn pong(ch, k) =
if k == 0 then ()
else
v = recv(ch)
println("pong: " + int_to_str(v) + " alındı")
pong(ch, k - 1)
fn main() =
ch = channel()
spawn ping(ch, 3)
pong(ch, 3)
ping: 3 gönderiliyor
ping: 3 teslim edildi
ping: 2 gönderiliyor
pong: 3 alındı
pong: 2 alındı
ping: 2 teslim edildi
ping: 1 gönderiliyor
ping: 1 teslim edildi
pong: 1 alındı
Satırların sırası ilk bakışta karışık görünebilir ama kuralı tam olarak yansıtıyor. Hiçbir "teslim edildi" satırı, o değerin karşı tarafta alınmasından önce gerçekleşmiş olamaz. Yazdırmalar el sıkışmadan sonra iki strand'de bağımsız devam ettiği için hangisinin önce ekrana düştüğü zamanlayıcıya kalmış. Garanti edilen şey sıra değil, eşzamanlama: bir send'in dönmesi, bir recv'in tamamlandığının kanıtıdır.

Tamponlu kanallar ve posta kutuları
channel(n) n değere kadar tampon tutar. Gönderen yalnızca tampon dolduğunda, alıcı yalnızca tampon boşaldığında bekler. Üreticinin önden koştuğunu görmek için tüketiciyi 50 ms uyutalım:
fn producer(ch, i, n) =
if i > n then ()
else
send(ch, i)
println("üretici: " + int_to_str(i) + " kuyruğa kondu")
producer(ch, i + 1, n)
fn main() =
ch = channel(3)
spawn producer(ch, 1, 5)
sleep_ms(50)
println("tüketici uyandı")
for _ in 0..5
println("tüketici: " + int_to_str(recv(ch)))
üretici: 1 kuyruğa kondu
üretici: 2 kuyruğa kondu
üretici: 3 kuyruğa kondu
tüketici uyandı
tüketici: 1
üretici: 4 kuyruğa kondu
tüketici: 2
tüketici: 3
tüketici: 4
tüketici: 5
Üretici ilk üç değeri tüketiciyi hiç beklemeden kuyruğa koydu, dördüncüde tampon dolu olduğu için park etti. Tüketici bir değer alınca uyandı. Tampon, üretici ile tüketici arasındaki hız farkını emiyor ve aynı zamanda doğal bir geri basınç (backpressure) sağlıyor: üretici, tüketicinin sınırsız önüne geçemiyor.
Üçüncü bir seçenek daha var: mailbox(). Posta kutusu sınırsız büyür, yani gönderen asla beklemez. Birbirine aynı anda mesaj gönderen iki taraf için idealdir, çünkü iki tamponlu kanal birbirini dolu bekleyip kilitlenebilir. Bedeli de açık: kimse göndereni yavaşlatmaz. Birazdan durum makinelerinde posta kutularının neden varsayılan olduğunu göreceğiz.
| Kanal | Gönderen ne zaman bekler? | Ne zaman kullanılır? |
|---|---|---|
channel() | alıcı gelene kadar her zaman | eşzamanlama, el sıkışma, "bitti" sinyali |
channel(n) | tampon doluyken | üretici/tüketici, iş kuyruğu, geri basınç |
mailbox() | hiçbir zaman | durum makineleri, karşılıklı mesajlaşma |
Bitirmek: close ve recv_opt
recv'de park etmiş bir strand, bir değer gelene kadar orada kalır. Değeri gönderecek şeyi yok etmek strand'i olduğu yerde, yığıtıyla birlikte bırakır. Bağlantı başına bir strand açan bir sunucuda bu, şimdiye kadar gelmiş bağlantı sayısı kadar sızıntı demektir. close tam olarak bunun için var:
fn produce(ch, n) =
if n <= 0 then close(ch)
else
send(ch, n)
produce(ch, n - 1)
fn drain(ch, acc) = match recv_opt(ch)
None -> acc
Some(v) -> drain(ch, acc + v)
fn main() =
ch = channel(4)
spawn produce(ch, 100)
println(drain(ch, 0))
println(closed(ch))
5050
true
Bu örnekte birkaç önemli tasarım kararı görünüyor:
closebir yayındır. Bir değer göndermek tek bir alıcıya ulaşır; kapatmak ise kanalda park etmiş herkese aynı anda ulaşır. Kapanan programın kaç strand'in beklediğini bilmesi gerekmez.- Tampondaki değerler kaybolmaz. Üretici söyleyecek şeyi bittiği anda kapatabilir; tüketici yoldaki her şeyi yine de görür. Yukarıdaki toplamın tam 5050 çıkması bunun kanıtı.
- İki kez kapatmak hata değildir. İki yönden çalışan bir kapanma sürecinin kimin önce geleceğini ayarlaması gerekmez.
recv_optbiten kanallar içindir. Değer varkenSome(v), kanal kapalı ve boşkenNonedöner.
Peki neden her recv bir Option döndürmüyor? Çünkü çoğu kanal hiç bitmez. Her birini Option yapmak, birkaçının bedelini ödetmek için dildeki her alımın önüne bir match koymak olurdu. Bunun yerine kapalı bir kanaldan düz recv yapmak programı durdurur ve ne yapılması gerektiğini söyler:
fn main() =
ch = channel(1)
close(ch)
println(recv(ch))
rill: recv on a closed channel - use recv_opt
Bu, tamponun dışındaki bir indeksin aldığı cevabın aynısı ve aynı nedenle: geri verilecek, yalan olmayan bir değer yok. Kapalı bir kanala göndermek de programı durdurur. Bundan kaçınmanın kuralı Go'nun da vardığı kural: gönderen kapatır.
Boru hatları
Kanallar ve close bir araya gelince, aşamaları birbirine bağlanmış bir boru hattı kurmak çok doğal oluyor. Her aşama girişindeki kanal kapanınca kendi çıkışını kapatıyor; böylece "bitti" bilgisi zincir boyunca kendiliğinden akıyor. 1'den 1.000.000'a kadar sayıları üretip 3'ün katlarını tutalım, karelerini alalım ve toplayalım:
fn numbers(out, i, n) =
if i > n then close(out)
else
send(out, i)
numbers(out, i + 1, n)
fn keep(inp, out, p) = match recv_opt(inp)
None -> close(out)
Some(v) ->
if p(v) then send(out, v) else ()
keep(inp, out, p)
fn square(inp, out) = match recv_opt(inp)
None -> close(out)
Some(v) ->
send(out, v * v)
square(inp, out)
fn total(inp, acc) = match recv_opt(inp)
None -> acc
Some(v) -> total(inp, acc + v)
fn main() =
a = channel(64)
b = channel(64)
c = channel(64)
spawn numbers(a, 1, 1000000)
spawn keep(a, b, \x -> x % 3 == 0)
spawn square(b, c)
println(total(c, 0))
111111277777611111
Sonucu bağımsız olarak doğruladım: 3'ün katlarının kareleri toplamı 9 · n(n+1)(2n+1)/6 formülünden n = 333.333 için tam olarak bu sayı. keep aşamasının bir lambda alması da önemli: aşamalar sıradan fonksiyonlar, tekrar kullanılabilir ve birbirleriyle istenildiği gibi birleştirilebilir. Aşamalar arasındaki 64'lük tamponlar da strand'lerin her değerde birbirini beklemesini önlüyor.
Eşzamanlı asal eleği
Go'yu öğrenen herkesin bir noktada karşılaştığı güzel bir örnek var: eşzamanlı Eratosthenes eleği. Bir strand 2'den başlayarak sayıları üretiyor. Ana akış zincirin ucundan aldığı ilk sayıyı asal kabul ediyor ve zincire "bu asalın katlarını ele" diyen yeni bir strand ekliyor. Rill'de şöyle görünüyor:
fn generate(ch, i) =
send(ch, i)
generate(ch, i + 1)
fn filter_out(inp, out, p) =
v = recv(inp)
if v % p != 0 then send(out, v) else ()
filter_out(inp, out, p)
fn sieve(ch, k, acc) =
if k == 0 then reverse(acc)
else
p = recv(ch)
next = channel()
spawn filter_out(ch, next, p)
sieve(next, k - 1, Cons(p, acc))
fn main() =
ch = channel()
spawn generate(ch, 2)
println(sieve(ch, 20, Nil))
Cons(2, Cons(3, Cons(5, Cons(7, Cons(11, Cons(13, Cons(17, Cons(19, Cons(23, Cons(29, Cons(31, Cons(37, Cons(41, Cons(43, Cons(47, Cons(53, Cons(59, Cons(61, Cons(67, Cons(71, Nil))))))))))))))))))))

İlk 20 asal için 21 strand ve 21 buluşmalı kanal çalışıyor. Ortada hiç ortak bellek ve hiç kilit yok; her strand yalnızca kendi giriş ve çıkış kanalını biliyor. generate ve filter_out sonsuz döngüler, ama ikisi de kuyruk çağrısı olduğu için yığıt büyümüyor. main döndüğünde program bitiyor ve geride kalan strand'ler, Go'da olduğu gibi bırakılıyor.
select: birden fazla kanalı aynı anda beklemek
Gerçek programlar nadiren tek bir kanalı bekler. select birkaç kanalı aynı anda bekler ve hangisi önce hazır olursa o kolu çalıştırır. Kollar sırayla denenir, yani bir beraberlikte önceki kol kazanır. Bir send kolu, gönderim yapılabildiğinde hazır olur. En fazla 64 kol olabilir.
Bloklamadan yoklamak: default
default kolu select'i bloklamayan bir yoklamaya çevirir: hiçbir kanal hazır değilse hemen o kol çalışır.
fn poll(ch) =
v = select
x = recv(ch) -> "hazır: " + int_to_str(x)
default -> "henüz bir şey yok"
v
fn main() =
ch = channel(1)
println(poll(ch))
send(ch, 42)
println(poll(ch))
henüz bir şey yok
hazır: 42
Bir saat ile bir işi birlikte izlemek
Rill'de bir Ticker tipi yok, çünkü bir ticker üç satır ve bir kanal. Bir ticker'ı ve uzun süren bir işi aynı select içinde izleyerek, iş bitene kadar düzenli ilerleme mesajı basan bir izleyici yazalım:
fn ticker(ch, ms) =
sleep_ms(ms)
send(ch, 1)
ticker(ch, ms)
fn job(done) =
sleep_ms(230)
send(done, "iş bitti")
fn watch(tick, done, n) =
r = select
msg = recv(done) -> msg + " (" + int_to_str(n) + " tik)"
t = recv(tick) ->
println(" ... hâlâ çalışıyor, tik " + int_to_str(n + t))
watch(tick, done, n + t)
r
fn main() =
tick = channel(1)
done = channel()
spawn ticker(tick, 50)
spawn job(done)
println(watch(tick, done, 0))
... hâlâ çalışıyor, tik 1
... hâlâ çalışıyor, tik 2
... hâlâ çalışıyor, tik 3
... hâlâ çalışıyor, tik 4
iş bitti (4 tik)
Ticker'a channel(1) vermek bir tasarım tercihi: izleyici meşgulken bir tik bekleyebilir ama tikler birikmez. Uyuyan bir strand işlemciyi hiç kullanmaz; bin strand aynı anda uyuyor olabilir ve program yine boştadır. Sabit hızlı bir döngü yazıyorsanız sabit bir süre değil, bir sonraki son tarihe kalan süre kadar uyumak gerekiyor. Aksi hâlde işletim sisteminin zamanlayıcılarındaki gecikmeler birikir.
Son tarih: timeout(ms)
Bir timeout(ms) kolu, başka hiçbir şey o süre boyunca hazır olmazsa hazır hâle gelir. Önceki yazıdaki örnekte bunu görmüştük; burada önemli olan mühendislik ayrıntısı şu: bu kol bir strand ya da kanal harcamıyor. Kanalların yanında poller'ın içinde park ediliyor. Uçuştaki her isteğe bir süre sınırı koyan bir sunucu bunun için istek başına bir strand ödemiyor. Birkaç kural:
- En kısa son tarih kazanır, yani bir bekleme hem bir uyarı hem bir sınır taşıyabilir.
- Geçmiş bir son tarih hemen hazırdır. Bu yüzden
timeout(0)bir yoklamadır,default'un başka bir yazımı. - Aynı
selectiçindedefaultiletimeoutbirlikte yazılamaz, çünküdefault"bekleme" demektir ve son tarihe hiç ulaşılamazdı.
Zarif kapanma: sinyaller ve close
Bir sunucunun Ctrl-C ile düzgünce kapanması için gereken iki parça elimizde: wait_signal() programa bir sinyal gelene kadar park eder ve hangisi olduğunu söyler; close ise bunu herkese yayınlar. Bir strand bekler ve diğerlerine haber verir; bekleyenin kaç strand olduğunu bilmesine gerek yoktur.
fn worker(id, stop, n) =
r = select
recv(stop) -> "işçi " + int_to_str(id) + " durdu, " + int_to_str(n) + " iş yapmıştı"
timeout(20) -> worker(id, stop, n + 1)
r
fn run(id, stop, report) = send(report, worker(id, stop, 0))
fn main() =
stop = channel()
report = channel(4)
for id in 0..3
spawn run(id, stop, report)
println("hazır, Ctrl-C bekleniyor")
s = wait_signal()
println("sinyal: " + int_to_str(s))
close(stop)
for _ in 0..3
println(recv(report))
Programı başlatıp yaklaşık yarım saniye sonra kill -INT gönderdim:
hazır, Ctrl-C bekleniyor
sinyal: 2
işçi 0 durdu, 25 iş yapmıştı
işçi 1 durdu, 25 iş yapmıştı
işçi 2 durdu, 25 iş yapmıştı
Buradaki incelik recv(stop) -> kolunun hiçbir şey bağlamaması. Kapalı bir kanal alım kolunu hazır yapar, ama üretecek bir değeri yoktur. x = recv(stop) -> diye yazılmış bir kol bir değer üretmek zorunda kalırdı ve program bunu söyleyerek dururdu. Böylece close(stop), diğer kol ne yapıyor olursa olsun her işçiye ulaşır.
Sinyal tarafında da dikkat edilmiş birkaç ayrıntı var. Sinyal, işletim sisteminin seçtiği herhangi bir iş parçacığına, herhangi iki komut arasında gelebilir. Bu yüzden orada programın yazdığı hiçbir şey çalışmaz: işleyici bir boruya tek bir bayt yazar, gerisi strand'de olur. SIGUSR1 ve SIGUSR2 bilinçli olarak yok, çünkü numaraları macOS'ta 30/31, Linux'ta 10/12. Yazıldığı yerde bir şey, çalıştığı yerde başka bir şey ifade eden bir sayı, hiç olmamasından daha kötü. Yapılandırmayı yeniden okumak için geleneksel SIGHUP var. Yalnızca sinyal bekleyen bir program kilitlenmiş sayılmaz, çünkü cevap verecek bir şeyi bekliyor.
Kilitlenme sessiz kalmaz
Herkesin birbirini beklediği bir program asılı kalmak yerine bunu söyler. İki strand birbirinden önce bir değer almayı beklerse:
fn main() =
a = channel()
b = channel()
spawn send(a, recv(b))
send(b, recv(a))
rill: deadlock - all strands are blocked
Çalışma zamanı, uyuyan ya da soket/sinyal bekleyen strand'leri kilitlenme saymaz, çünkü onlar cevap verecek bir şeyi bekliyor. Yalnızca herkes bir kanalda birbirini bekliyorsa durumu raporlayıp programı durduruyor.
Durum makineleri: machine
Bir sinyal bekleyen, sinyalin istediğini bulunduğu duruma göre yapan ve tekrar bekleyen bir strand; telefon santrallerinin process dediği ve SDL ile yazdığı şey. Rill'de bu, bir kanal ve bir durum üzerinde kuyruk özyinelemeli bir fonksiyon. machine bu fonksiyonu olduğu makine olarak yazmanın yolu. Para atınca açılan, geçince ya da 3 saniye kimse geçmezse yeniden kilitlenen bir turnike yazalım:
type Signal
Coin(kurus: Int)
Push
Report
Status(text: Str)
type Gate
Locked(credit: Int)
Open
machine gate(me: Chan(Mail(Signal)), log: Chan(Str))
state Locked(credit)
Coin(k) if credit + k >= 500 ->
send(log, "jeton tamam, kapı açıldı")
Open
Coin(k) ->
send(log, "bakiye: " + int_to_str(credit + k))
Locked(credit + k)
Push ->
send(log, "kilitli, önce ödeme")
stay
state Open
Push ->
send(log, "geçildi, kilitlendi")
Locked(0)
Coin(_) ->
send(log, "zaten açık, para iade")
stay
after 3000 ->
send(log, "kimse geçmedi, yeniden kilitlendi")
Locked(0)
state *
Report ->
reply(Status("rapor istendi"))
stay
fn show_log(log, n) =
if n == 0 then ()
else
println(recv(log))
show_log(log, n - 1)
fn main() =
env = mailbox()
g = mailbox()
log = channel(16)
spawn gate(g, log, Locked(0))
post(g, env, Push)
post(g, env, Coin(250))
post(g, env, Coin(250))
post(g, env, Coin(100))
post(g, env, Push)
post(g, env, Coin(500))
show_log(log, 6)
println(recv(log))
post(g, env, Report)
println(recv(env).signal)
kilitli, önce ödeme
bakiye: 250
jeton tamam, kapı açıldı
zaten açık, para iade
geçildi, kilitlendi
jeton tamam, kapı açıldı
kimse geçmedi, yeniden kilitlendi
Status(rapor istendi)
Makinenin okunuşu neredeyse bir şartname gibi:
- Durumlar, sıradan bir tipin (
Gate) kurucuları. Alanlarının tipleri bir kez yazılıyor. - Her
statealtında, cevap verdiği sinyaller birmatch'in kolları gibi duruyor. Kolun değeri bir sonraki durum.staybulunulan durumda kalmak,stopise strand'i bitirmek demek. after 3000 ->o süre boyunca sinyal gelmezse alınan kol.state *her durumun cevap verdiği kollar; burada her durumda rapor verebiliyoruz.- Gelen her şey bir
Mail: sinyal ve onu gönderen posta kutusu.reply(sig)göndericiye cevap veriyor; sinyalin bir cevap kanalı taşımasına gerek yok.maindışarıdan konuşurkenpost(to, from, sig)ile cevabın geleceği posta kutusunu (env) söylüyor. - Hiçbir kolun adlandırmadığı bir sinyal tüketiliyor ve durum korunuyor; SDL'in beklenmeyen bir sinyalle yaptığı da bu. Tipin her durumu makinede görünmek zorunda, yoksa derleyici eksik olanı söylüyor.
Derleyici bu makineyi tam olarak elle yazacağınız fonksiyona çeviriyor: durum üzerinde bir match, her durumda bir recv (ya da after varsa timeout'lu bir select) ve kendini bir sonraki durumla çağıran kuyruk çağrıları. Bekleme dışında ek bir maliyeti yok.
Daha da güzeli, makine kod olmadan önce bir diyagram. rill diagram turnstile.rill --svg aşağıdaki SDL süreç diyagramını doğrudan bildirimden çiziyor. Diyagram koddan okunuyor ve yanında ayrıca tutulmuyor, yani asla geride kalamıyor:

Sanal zaman: modeli test etmek
Turnikenin son satırı 3 saniyelik after yüzünden gerçekten 3 saniye bekliyor. RILL_VIRTUAL_TIME=1 ile program kendi saatiyle çalışıyor: bir şey çalışırken zaman geçmiyor, her strand beklerken ve en yakın beklenen şey bir zamanlayıcıyken saat doğrudan ona atlıyor.
$ time ./turnstile
real 3.18
$ RILL_VIRTUAL_TIME=1 time ./turnstile
real 0.00
Çıktı birebir aynı, süre ise 3,18 saniyeden sıfıra iniyor. Üstelik sanal zamanla bir çalıştırmanın izi her seferinde aynı oluyor; bu da bir modelin davranışını test edilebilir kılıyor. RILL_TRACE=1 her makine olayını bir satır olarak yazıyor ve rill msc bu satırları SDL araçlarının çizdiği mesaj sıralama şemasına çeviriyor.
Makinelerin daha birçok yeteneği var: kendine sonra sinyal gönderen zamanlayıcılar (set(Retry, 200)), bir sinyali sonraki bir duruma saklamak (save), önceliklendirmek (priority), iç içe durumlar ve makineler arası yolları derleme zamanında denetleyen system blok diyagramları. Bunlar başka bir yazının konusu.
Ölçek: bir milyon strand
Strand'lerin ne kadar ucuz olduğunu kendim ölçmek istedim. Aşağıdaki program N strand başlatıyor; her biri bir "dur" kanalında park ediyor. Ardından tek bir close ile hepsi uyandırılıyor ve her biri bir posta kutusuna haber veriyor:
fn parked(stop, done) =
r = select
recv(stop) -> 1
send(done, r)
fn count(done, k, acc) = if k == 0 then acc else count(done, k - 1, acc + recv(done))
fn main() =
n = str_to_int(unwrap_or(nth(args(), 1), "1000"))
stop = channel()
done = mailbox()
t0 = now_nanos()
for _ in 0..n
spawn parked(stop, done)
yield()
sleep_ms(1)
t1 = now_nanos()
close(stop)
woke = count(done, n, 0)
t2 = now_nanos()
println(int_to_str(n) + " strand: başlat+park " + int_to_str((t1 - t0) / 1000000) + " ms, close ile uyandır " + int_to_str((t2 - t1) / 1000000) + " ms, uyanan " + int_to_str(woke))
1000 strand: başlat+park 1 ms, close ile uyandır 0 ms, uyanan 1000
10000 strand: başlat+park 5 ms, close ile uyandır 1 ms, uyanan 10000
100000 strand: başlat+park 22 ms, close ile uyandır 11 ms, uyanan 100000
1000000 strand: başlat+park 162 ms, close ile uyandır 124 ms, uyanan 1000000

Bir milyon strand 162 ms'de başlayıp park ediliyor ve tek bir close yayınıyla 124 ms'de hepsi uyanıyor. Süreler ve bellek doğrusal ölçekleniyor. Bellek konusunda dürüst olmak gerekirse: bu programda strand başına yaklaşık 1,5 KB düşüyor. Bu, deposundaki "park edilmiş strand ≈ 300 bayt" rakamından yüksek, çünkü burada her strand bir select içinde bekliyor ve uyanınca sınırsız bir posta kutusuna yazıyor; milyon mesajlık o kutu da belleğin bir parçası. Bunlar tek çalıştırmalık ölçümler; amacım kesin bir sayıdan çok büyüklük mertebesini göstermek.
Birden fazla çekirdek
Varsayılan olarak bütün strand'ler tek bir işletim sistemi iş parçacığında çalışıyor. Referans sayımının atomik olmayan, dolayısıyla hızlı işlemlerle yapılabilmesini sağlayan şey bu. Gerçek paralellik iki kez açıkça istenir: derlerken ve çalıştırırken.
rill build main.rill -o main --parallel # referans sayımı çok iş parçacığına uygun hâle gelir
RILL_THREADS=8 ./main # sekiz işçide çalıştır
İşçiler henüz başlamamış strand'leri birbirinden çalıyor. Çalışmaya başlamış bir strand ise kendi işçisinde kalıyor, çünkü yığıt çerçeveleri o işçinin adreslerinde yaşıyor. Paralellik, strand'ler bağımsız hesaplama yaptığında işe yarıyor. Bir strand'in değeri bir sonrakine verdiği buluşma zincirlerinde (yukarıdaki elek ya da halka gibi) örtüşecek bir iş yok; bunlar tek işçide en hızlı çalışıyor.
Paylaşım derleme zamanında denetlenir
İki strand'in fikir ayrılığına düşebileceği tek tür değer depolama: bir Buf, bir Map, bir StrBuf ya da bunlardan birini tutan herhangi bir şey. Birinin yazdığı sırada diğerinin okuması ya da yazması bir yarış (race) demek. --parallel derleme, program çalışmadan önce strand'lerin neyi paylaştığını denetliyor:
fn fill(b: Buf(Int), v) = for i in 0..#b
b[i] = v
fn main() =
b = buf_int(8)
spawn fill(b, 1)
spawn fill(b, 2)
println(int_to_str(b[0]))
$ rill check share.rill --parallel
share.rill: type error at line 6: `b` is written by this strand and still used here after
the `spawn`: two strands writing one piece of storage is a race. Hand it over (do not use
it after the spawn), or send each strand its own, or, where the writes are kept apart on
purpose, say so: `apart(b)`
Kurallar basit:
- Bir strand'e verilen (
spawnile yakalanan ya da kanaldan gönderilen) ve veren tarafın sonradan kullanmadığı bir değer artık o strand'indir. Her şey serbest. - Sonradan kullanılan bir değer paylaşılmıştır. Okumak için paylaşmak sorun değil. Paylaşıp yazmak reddedilir ve hata değeri ve satırı söyler.
- Bir fonksiyon değeri opaktır: neyi tuttuğu ve neye yazdığı tipinde görünmez. Program bildiğini söylemedikçe strand'ler arasında paylaşılması reddedilir.
Programın bildiği şey apart(x) ile söyleniyor: "bu değer bilerek paylaşılıyor ve yazmalar birbirinden ayrı tutuluyor". Her strand bir tamponun kendi aralığına yazıyor olabilir. Derleyici bu iddiayı denetleyemez ama bulunacak bir iddia olduğundan emin olur. Tek işçili bir derlemede strand'ler yalnızca bekledikleri yerlerde iç içe geçtiği için aynı program orada denetlenmiyor.
Gerçek bir örnek: bu blogun sunucusu
Bu sayfa da tam olarak bu araçlarla sunuluyor. Rill'in HTTP sunucusu her bağlantı için bir strand açıyor; sessiz bir bağlantının strand'i park ediliyor ve hiçbir şey tutmuyor. Veritabanı bağlantıları tamponlu bir kanalın içinde duran bir havuz: bir istek bir bağlantı alıyor, kullanıyor ve geri koyuyor. Bağlantı bulamayan istek bekliyor ve bu da sunucuya bedava bir geri basınç sağlıyor:
fn with_conn(pool, ask) =
fd = recv(pool)
res = ask(fd)
send(pool, fd)
res
Bir yazı okunduğunda okunma kaydı veritabanına ayrı bir strand'de yazılıyor; sayfa, veritabanının diske yazmasını beklemiyor:
fn spawn_view(app: App, slug, referrer) =
spawn with_conn(app.db, \fd -> record_view(fd, slug, referrer))
()
Burada spawn'ın ifadenin tamamını yeni strand'de çalıştırması tam istediğimiz şey: havuzdan bağlantı almak da, yazmak da, bağlantıyı geri koymak da isteği cevaplayan strand'in dışında oluyor.
Özet
channel()bir el sıkışmadır:send'in dönmesirecv'in tamamlandığının kanıtıdır.channel(n)hız farkını emer ve geri basınç sağlar.mailbox()asla beklemez.closebir yayındır, tampondakini kaybetmez ve iki kez yapılabilir.recv_optbiten kanallar içindir. Kuralı unutmayın: gönderen kapatır.selectbirden fazla kanalı bekler.defaultbloklamayan bir yoklama,timeout(ms)ise strand harcamayan bir son tarihtir.wait_signal+closezarif kapanmanın iki yarısıdır. Gerçek bir kilitlenme ise sessiz kalmaz.machinebir strand'i SDL tarzı bir durum makinesi olarak yazar;rill diagramonu çizer, sanal zaman onu test edilebilir kılar.--parallelgerçek paralellik verir ve paylaşılan depolamaya yazmayı derleme zamanında reddeder; bilinçli istisnalarapartile söylenir.
Strand'ler o kadar ucuz ki tasarım sorusu "kaç iş parçacığı açabilirim?" olmaktan çıkıp "bu işin doğal yapısı ne?" sorusuna dönüşüyor. Bir bağlantı bir strand, bir aşama bir strand, bir asal bir strand.
Bir sonraki yazıda bu blogun arkasındaki veritabanı OxiDB'yi anlatacağım. Sorularınızı ve denemelerinizi bana X ya da LinkedIn üzerinden iletirseniz sevinirim.