3 puan yazan GN⁺ 2023-07-22 | 1 yorum | WhatsApp'ta paylaş
  • Rust öğrenmek için yapılan yan proje rjvm, öğrenme amaçlı oyuncak bir JVM olsa da gerçek .class ve jar dosyalarını okuyup Java bytecode'unu çalıştıracak seviyeye kadar uygulanmış
  • Desteklenmeyen özellikler thread, reflection, annotation, I/O, JIT derleyici ve string interning olsa da kontrol akışı, nesne oluşturma, metot çağrıları, exception ve garbage collection dahil
  • Örnekler, OpenJDK 7'nin gerçek rt.jar dosyasını kullanıyor; java.lang.StackTraceElement gibi sınıflar gerçek JDK içinden yükleniyor
  • Kod, üç Rust crate'ine ayrılıyor: reader, vm, vm_cli; .class ayrıştırmadan çağrı yığınına, native metotlara, değer ve nesne modellemeye kadar uzanıyor
  • Son kilometre taşı stop-the-world semispace copying collector oldu; hedefe ulaşıldıktan sonra proje durduruluyor ve bilinen hataları düzeltme planı bulunmuyor

Öğrenme amaçlı Rust JVM rjvm

  • rjvm, Rust öğrenmek için yapılmış oyuncak bir Java Virtual Machine
  • Kod GitHub üzerinde açık olarak bulunuyor
  • Ciddi bir JVM implementasyonundan çok öğrenme amaçlı bir çalışma olduğu için şu özellikleri desteklemiyor
    • thread
    • reflection
    • annotation
    • I/O
    • JIT derleyici
    • string interning
  • Generics başlangıçta desteklenmeyenler listesinde yer alıyordu ama pratikte çalışıyor

Ne kadar uygulanmış?

  • rjvm, basit bir parser olmanın ötesine geçip çeşitli JVM davranışlarını doğrudan uyguluyor
    • if, for gibi kontrol akışı
    • ilkel tipler ve nesne oluşturma
    • sanal metot ve statik metot çağrıları
    • exception işleme
    • garbage collection
    • jar dosyalarından sınıf çözümleme
  • Test kodunda Throwable, Exception, StackTraceElement kullanan örnekler bulunuyor
  • Gerçek I/O olmadığı için System.out.println yerine tempPrint adlı bir native metot kullanılıyor
  • Örnekler, OpenJDK 7'nin gerçek rt.jar dosyasını kullanıyor ve java.lang.StackTraceElement de gerçek JDK'dan alınıyor

Üç Rust crate'i

  • Proje standart bir Rust projesi ve üç crate'ten oluşuyor
    • reader: .class dosyalarını okuyup içeriklerini modelleyen tipleri içeriyor
    • vm: kod çalıştırabilen sanal makineyi bir kütüphane olarak sunuyor
    • vm_cli: java çalıştırılabilir dosyasına benzeyen basit bir komut satırı başlatıcısı içeriyor
  • reader crate'ini ayrı bir depoya ayırıp crates.io üzerinde yayımlama seçeneği değerlendiriliyor

.class dosyası ayrıştırma

  • Java kodu javac ile derlenip .class dosyasına dönüşür ve genelde zip formatındaki .jar dosyalarıyla dağıtılır
  • Java kodunu çalıştırmak için önce derleyicinin ürettiği bytecode'u içeren .class dosyasını yüklemek gerekir
  • Sınıf dosyasında çalıştırma ve tip çözümleme için gereken bilgiler de bulunur
    • sınıf adı ve kaynak dosya adı gibi metadata
    • üst sınıf adı
    • implemente edilen arayüzler
    • alanlar, alan tipleri ve annotation'lar
    • metot descriptor'ları, throws bölümü, annotation'lar ve generics bilgileri
    • bytecode, exception handler tablosu, satır numarası tablosu
  • reader crate'i sınıf dosyasını ayrıştırıp sınıfı ve içeriğini modelleyen bir Rust struct döndürüyor

Metot çalıştırma ve çağrı yığını

  • vm crate'inin temel API'si Vm::invoke ve metot çalıştırmak için kullanılıyor
  • Çalışan her metot, CallStack içinde bir CallFrame sahibi
  • main çalıştırıldığında çağrı yığını başlangıçta boştur ve yürütme için yeni bir frame oluşturulur
  • Her fonksiyon çağrısında çağrı yığınına yeni bir frame eklenir; metot çalışması bitince ilgili frame kaldırılır
  • Metotların çoğu Java bytecode'u ile uygulanmış olsa da rjvm native metotları da destekliyor
    • Native metotlar, Java bytecode'u yerine doğrudan JVM'nin kendisi tarafından uygulanan metotlardır
    • System::currentTimeMillis, System::arraycopy, Throwable::fillInStackTrace buna örnek
    • rjvm içinde bunlar Rust fonksiyonları olarak uygulanıyor
  • JVM, stack tabanlı sanal makine olduğu için bytecode komutları çoğunlukla değer yığını üzerinde çalışır
  • Her çağrı frame'ine bir değer yığını ve indekslerle tanımlanan yerel değişkenler kümesi eşlik eder

Değer ve nesne modelleme

  • Value, yerel değişkenlerde, stack öğelerinde ve nesne alanlarında saklanabilen değerleri modelliyor
  • Value, Rust enum olarak uygulanmış ve şu durumları içeriyor
    • Uninitialized
    • Int(i32)
    • Long(i64)
    • Float(f32)
    • Double(f64)
    • Object(AbstractObject<'a>)
    • Null
  • Rust enum'ları gibi sum type yapıları, tek bir değerin birden fazla tipten biri olabilmesini ifade etmek için çok uygun
  • Nesne depolama ilk başta, sınıf referansı ve alan değerlerini taşıyan Vec<Value> tabanlı basit bir Object struct'ı olarak başladı
  • Garbage collector uygulandıktan sonra yapı, pointer ve cast kullanımının yoğun olduğu daha düşük seviyeli bir implementasyona dönüştü
  • Şu anki AbstractObject, gerçek nesneleri veya dizileri modelliyor; birkaç header word'ü ve alan değerlerini içeren bir byte array pointer'ı şeklinde

Bytecode komutlarının çalıştırılması

  • Metot yürütme, bytecode komutlarını tek tek işleme sürecidir
  • JVM komutları 200'den fazladır ve bytecode içinde 1 byte ile kodlanır
  • Birçok komutun ardından argüman gelir ve bazı komutlar değişken uzunluktadır
  • rjvm, Java bytecode komutlarını Instruction tipiyle modelliyor
  • Metot çalıştırılırken değer yığını ve yerel değişken dizisi tutulur, bir sonraki çalıştırılacak komutun adresi olan program counter ise 0 olarak başlatılır
  • Normalde komut yürütüldükten sonra program counter bir sonraki komuta ilerler, ancak jump komutları onu başka bir konuma taşıyabilir
  • Jump komutları if, for, while gibi akış kontrol yapılarının uygulanmasında kullanılır
  • Başka metotları çağıran komutlar ayrı bir aile oluşturur
    • Hangi metodun çağrılacağını belirleme yöntemleri arasında sanal çözümleme ve statik çözümleme bulunur
    • Hedef metot çözümlendikten sonra çağrı yığınına yeni bir frame eklenir ve yürütme başlar
    • Dönüş değeri void değilse bu değer stack'e push edilir ve yürütme sürer

Exception işleme

  • Exception'lar normal kontrol akışını bozar, metottan erken dönüşe yol açar ve çağrı yığını boyunca yayılabildiği için implementasyonu karmaşıktır
  • Her catch bloğu, metodun exception table yapısındaki tek bir girdiye karşılık gelir
  • Exception table girdisinde handler bulmak için gereken bilgiler yer alır
    • geçerli olduğu program counter aralığı
    • catch bloğunun ilk komut adresi
    • ilgili bloğun yakaladığı exception sınıfının adı
  • CallFrame::execute_instruction, komut yürütme sonucunu ifade etmek için Rust Result kullanır
  • Komut yürütme sonucu dört duruma ayrılır
    • başarılıdır ve mevcut metot yürütmesi devam eder
    • başarılıdır ve return komutu olduğu için mevcut metot dönüş değeriyle birlikte sona erer
    • dahili VM hatası nedeniyle yürütme başarısız olur
    • bir Java exception'ı fırlatıldığı için yürütme başarısız olur
  • Metot yürütme döngüsü komutu ayrıştırır, program counter'ı sonraki adrese taşır ve sonra komutu çalıştırır
  • Exception oluşursa mevcut komut konumuna uygun bir exception handler aranır
    • handler yoksa exception çağırana iletilir
    • handler varsa exception nesnesi tekrar stack'e push edilir ve yürütme catch handler konumunda devam eder
  • Rust Result ve pattern matching, bu davranışı kod yapısı olarak ifade etmek için çok uygundur

Garbage collection

  • rjvm'in son büyük kilometre taşı garbage collector implementasyonuydu
  • Seçilen algoritma stop-the-world semispace copying collector
  • Thread olmadığı için stop-the-world yaklaşımı doğal olarak geçerlidir
  • Implementasyon, Cheney's algorithm'in daha basit bir varyantı ve kod gc.rs içinde bulunuyor
  • Bu yaklaşım kullanılabilir belleği iki semispace'e böler
    • biri nesne tahsisi için kullanılan aktif alan olur
    • diğeri kullanılmayan alan olarak kalır
    • aktif alan dolduğunda yaşayan nesneler diğer semispace'e kopyalanır
    • tüm nesne referansları yeni kopyaları gösterecek şekilde güncellenir
    • iki semispace'in rolleri değiştirilir
  • Bu prosedür blue-green deployment'a benzer bir yaklaşımla açıklanıyor
  • Algoritmanın artıları ve eksileri nettir
    • en fazla belleğin yarısı kullanılabildiği için büyük bellek israfı yaratır
    • pointer artırma temelli olduğu için allocation çok hızlıdır
    • nesneleri kopyalayıp sıkıştırdığı için bellek parçalanmasıyla uğraşmak gerekmez
    • nesne sıkıştırma, cache line kullanımını iyileştirerek performansı artırabilir
  • Gerçek Java VM'leri genellikle G1 veya parallel GC gibi daha gelişmiş generational garbage collector yapıları kullanır

Projenin bittiği nokta

  • rjvm yapılırken Rust ve sanal makine implementasyonu hakkında çok şey öğrenildi; özellikle gerçekten çalışan bir garbage collector yapabilmek tatmin ediciydi
  • Garbage collector çok olgun değil ama gerçekten çalışıyor
  • Başlangıçtaki hedefe ulaşıldığı için proje burada duruyor
  • Bilinen hatalar var ama bunları düzeltme planı yok
  • Rust, JVM implementasyonu sırasında keyifli bir dil oldu; sonraki yazılarda rjvm implementasyonu ve JVM'nin çalışma biçimi daha ayrıntılı ele alınacak

1 yorum

 
GN⁺ 2023-07-22
Hacker News görüşleri
  • Çöp toplayıcı uygulamasındaki zor kısım, tüm referansların düzgün biçimde kök olarak yakalandığından emin olmak; özellikle de taşımalı toplayıcılarda bu daha da zor
    do_garbage_collection metodu unsafe olarak işaretlenmiş[1], ancak bunu çağıran tarafın güvenli biçimde çağırabilmesi için neyi garanti etmesi gerektiğine dair bir açıklama yok
    Heap’e olan tüm referansların kök olarak yakalandığını nasıl garanti ettiklerini merak ediyorum ve bu önemsiz bir mesele değil[2][3][4]
    Ayrıca depoyu klonlayıp cargo test çalıştırdım ve tüm testler şu hatayla başarısız oldu: should be able to add entries to the classpath: InvalidEntry(".../vm/rt.jar"): vm/tests/integration/real_code_tests.rs:15:10
    [1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
    [2] https://manishearth.github.io/blog/2021/04/05/a-tour-of-safe...
    [3] https://without.boats/blog/shifgrethor-iii/
    [4] https://coredumped.dev/2022/04/11/implementing-a-safe-garbag...

    • Bu VM, yerel çağrı yığını yerine kendi sanal çağrı yığınını tuttuğu için oldukça sezgisel
      Bu yüzden o yığını dolaşıp parametreleri ve yerel değişkenleri bularak kök olarak kullanabiliyor
      Bu yaklaşımın performans maliyeti var, ancak çöp toplayıcı izlemesi çok daha basit hale geliyor ve coroutine ya da continuation gibi eşzamanlılık ve kontrol akışı ilkel özelliklerini uygulamak da kolaylaşıyor
    • Çöp toplayıcı temelde titiz bir muhasebe işi olduğu için oldukça kolay sayılır, ama eşzamanlı çöp toplama işine girdiğiniz anda cehennem gibi zorlaşıyor
  • Güzel bir proje, tebrikler
    Ancak “desteklenmeyenler: generics” kısmı biraz tuhaf geldi
    JVM’de generics desteğinin tam olarak ne anlama gelmesi gerektiğini merak ediyorum
    Bytecode düzeyinde type erasure nedeniyle her şeyi Object, yani referans türü olarak düşünmek yeterli değil mi? Yoksa kastedilen sınıf tanımı ayrıştırıcısı mı? Öyle olsa bile, temel sözdiziminin ötesinde sınıf dosyasının geçerli olup olmadığını doğrulayan bir mantık görünmüyor

    • Generics konusunda reddit’te de aynı şey söylenmişti ve bu doğru
      Aslında yapılması gereken, sınıf, metot ve alanların generic bilgisini taşıyan Signature özniteliğini okumak kadar basit (https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.ht...)
      Hatta az önce test ettim, aşağıdaki kod da çalışıyor :-)
      public class Generic { public static void main(String[] args) { List strings = new ArrayList(10); strings.add("hey"); strings.add("hackernews"); for (String s : strings) { tempPrint(s); } } private static native void tempPrint(String value); }
    • Belki de checkcast işlemini kastediyordur: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.ht...
      Örneğin final Main value = list.get(0); gibi bir kod yazarsanız üretilir
      http://henrikeichenhardt.blogspot.com/2013/05/how-are-java-g...
    • Genel olarak doğru. Generics nadiren reflection üzerinde etkili olur, ama reflection da zaten desteklenmiyor ve genel olarak derleme zamanında en yakın sınıf ya da arayüzle değiştirilir
      Buna karşılık string interning olmaması çok tuhaf. Uygulaması da oldukça önemsiz; bu olmadan buna JVM demek zor
      String’lerin referans temelinde eşit hale gelmesi önemlidir ve JLS’nin bir parçasıdır
      Thread’lerin olmaması ise tüm girişimi oyuncak proje seviyesine indiriyor
  • Gerçekten harika. 1992'de Java üzerinde çalışmaya katıldığımda adı Oak idi ve içinde bulunduğum grup, tüm işletim sistemini Java ile yazma yönünü hedefliyordu
    Fikir, yalnızca gereken en küçük kısmı “makine dili”, yani native method olarak bırakıp gömülü işletim sisteminin saldırı yüzeyini azaltmaktı
    Java başlangıçta TV'ler veya ev aletleri gibi yerlerde çalışmayı hedefliyordu ve o dönemde native method'lar Rust ile değil C ile yazılıyordu
    Rust ile yapılmış bir JVM, bu sürecin tamamına oldukça güçlü bir bellek güvenliği katıyor

  • Go ile yazılmış bir JVM 17 implementasyonu olarak https://jacobin.org/ da bakmaya değer

    • JVM üzerinde çalışan Scala ile yazılmış bir JVM olan https://github.com/lihaoyi/Metascala da var
    • Programlama projesi adı olarak oldukça ilginç
      Jacobins, 1790'larda Fransız Devrimi döneminin devrimci siyasi kulübüydü ve aynı zamanda https://jacobin.com adresindeki derginin adı
  • Bu signature'daki lifetime parametreleri yüzünden sınıra dayanıp dayanmadığını merak ediyorum
    fn execute_instruction(&mut self, vm: &mut Vm<'a>, call_stack: &mut CallStack<'a>, instruction: Instruction) -> Result, MethodCallFailed<'a>>
    Result içindeki Err varyantına bir lifetime eklenip bu lifetime invariant olduğunda, burada vm ve call_stack nedeniyle olduğu gibi, genelde ? operatörünü veya erken dönüşü kullanamazsınız[1]
    Bu durumda hata işleme daha ayrıntılı ve okunması zor hale geliyor; pratikte de deneyim böyle miydi diye merak ediyorum
    [1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...

  • Harika bir öğrenme projesi ve yazarın eğlenerek yaptığını görmek güzel
    Bir VM'i sıfırdan implement etmek gerçekten çok keyiflidir ve ben de geçmişte böyle işler yaparken çok şey öğrendim
    Garbage collection eklemek istersen MMTk'ye de bakabilirsin (https://www.mmtk.io/)
    Birden fazla VM'e takılabilecek şekilde tasarlanmış, yüksek kaliteli toplama algoritmaları var ve Rust ile yazılmış

    • Not olarak, MMTK yalnızca x86 için
      Oyuncak bir projede kullanmayı denemiştim ama Mac kullandığım için vazgeçtim
  • Çok iyi yapılmış
    VM yapmak her zaman eğlencelidir ve Rust'ın type system'i ile birleşince ilginç bir öğrenme deneyimi olmuştur diye düşünüyorum
    İş arıyorsan Twitter, Mastodon veya şirket e-postası üzerinden iletişime geçebilirsin. Buradaki kullanıcı kimliğine bakarak seni bulabilirler

  • Böyle harika projeleri görünce insan kendini ezilmiş hissediyor.
    Buna benzer bir şeyi deneyebilmek için Rust’a nasıl başlamak ve temelleri ne kadar öğrenmek gerektiğini, keşke asıl yazar anlatsa.

    • Ben de aynı şekilde hissediyorum.
      Konudan çok sapmak istemem ama son zamanlarda kişisel olarak bu duyguyla epey mücadele ediyorum.
      Yaklaşık 10 yıldır profesyonel yazılım geliştiricisi olarak çalışıyorum; mevcut unvanıma ve gerçekten ürün çıkarabilme becerime bakınca yetkin olduğumu ve sahte bir geliştirici olmadığımı da biliyorum.
      Ama son zamanlarda geliştirici bloglarını okuyunca, sanki yeterince şey bilmiyormuşum ve “gerçek” bir geliştirici değilmişim gibi eziliyorum.
      Sanırım kafamda ideal bir geliştirici profili oluşturup kendimi o hayali ölçütle kıyasladığım için böyle hissediyorum.
      Derin bilgiye sahip olup kendini açık ve öz biçimde ifade eden insanlara hayranlık duyarken, neden ben öyle değilim diye de düşünüyorum.
      İşten sonra ailemle ilgilenince başka bir şey yapacak neredeyse hiç enerjim kalmıyor; programlamanın hayatın tamamı olmadığını da biliyorum ama yine de daha çok öğrenmek ve gelişmek istiyorum.
      Bunun ne sağlıklı ne de makul olduğunu biliyorum ama son zamanlarda üzerimden atması zor bir duygu.
    • Dürüst olmak gerekirse, HN’yi her açtığımda yarı yarıya imposter syndrome hissediyorum.
      Eskiden biraz VM deneyimim vardı ve birkaç yıl önce blogumda bununla ilgili kısa bir yazı dizisi de yazmıştım.
      Önceki işimde, bir müşterinin çok sıra dışı sorununu çözmek için biraz JVM bytecode’u kurcalamam da gerekmişti.
      Ayrıca birkaç yıl önce harika https://craftinginterpreters.com/ kitabını okudum ve oradan ilham aldım.
      Ama bu proje kesinlikle büyük ve karmaşıktı.
      Çok zaman aldı ve pek çok yan projem gibi birkaç kez bir kenara bırakıldı, ama sonunda bitirmiş olmaktan mutluyum :-)
    • Rust uzmanı değilim ve asıl yazar da ben değilim, ama başka bir teknoloji olan socket üzerinden konuşabilirim.
      Son zamanlarda socket konusuna derinlemesine girdim; daha 2 hafta önce elimde sadece man sayfaları, dokümantasyon ve blog yazılarını üstünkörü okuyarak edinilmiş yüksek seviyeli bir anlayış vardı.
      Ağ temellerini anlamak istediğim için bulabildiğim kadar çok şey okuyup incelemeye karar verdim ve bir hafta sonra Python ile C’de socket kodu yazabilecek kadar öğrenmiştim.
      Python’ı zaten oldukça iyi bildiğim için, derinlemesine girdikten sonra sockets kütüphanesine bakmak bana çok daha anlamlı geldi.
      Eğer teknoloji A’yı dil X ile daha iyi yapmak istiyorsanız, teknoloji A hakkında olabildiğince çok okuyup izlemeyi ve dil Y ile bir şeyler yapmayı tavsiye ederim.
      Sonra dil X’e döndüğünüzde, teknoloji A’nın etrafındaki kavramlara zaten oldukça aşina olmuş olursunuz.
    • Mesele onu küçük parçalara bölmek.
      Basit bir dil VM’inde bellekte nesne gösterimi, bir bytecode interpreter, basit bir çöp toplayıcı ve bir yükleyici olur.
      Bytecode interpreter’ı; bir yığın, bu yığın üzerinde fonksiyonları temsil etme yöntemi ve her bytecode’u yorumlayıp program sayacını ilerleten bir döngü olarak görebilirsiniz.
    • Boş zamanda ne kadar kod yazdığınız önemli. Ortalama olarak haftada kaç saat olduğu yani.
      Eğer bu süre 0 saatse, elbette bu kınanacak bir şey değil ve odaklanmanız gereken başka şeyler de olabilir; ama yıllar boyunca yan projelerine haftada ortalama 10-20 saat ayıran birinin etkileyici sonuçlar çıkarması da şaşırtıcı değil.
  • Benzer bir projeyi biraz utanmazca tanıtmak gerekirse: https://github.com/tenaf0/rust-jvm3

  • 386(486) AT klonları için ücretsiz bir işletim sistemi yapıyorum. Sadece hobi amaçlı; GNU kadar büyük ve profesyonel bir şey değil :-)

    • Rust ile demo çekirdek yazmaya yönelik bir no-std eğitimi var: https://os.phil-opp.com
      osdev.org, sandpile.org, RBIL ve freevga da bakmaya değer.
      En büyük baş ağrısı donanım desteği.
      Güvenilir port I/O veya belgelenmemiş donanım hileleri gibi tarifler içeren iyi eski basılı kitaplar da çok var.
      Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4
      Microsoft MS-DOS Programmer's Reference; buna real-mode BIOS çağrıları da dahil.
      PC Interrupts
      Undocumented PC
      PC Intern
      Programmer's Guide To The EGA, VGA, And Super VGA Cards
      Graphics Programming Black Book Special Edition
      Ayrıca tek çekirdekli, mikrokernel ve hibrit dönemlerinden sonraki işletim sistemi geliştirme ilerlemelerini denemek de değerli olabilir.
      seL4 gibi capability tabanlı yapılar, capability ve mükemmel IPC dahil olmak üzere performans ve güvenlik açısından özünde birçok avantaj taşır.
      POSIX uyumluluk katmanı da önemli. Thread veya process kavramı olmayan gömülü işletim sistemleri bile POSIX uygulayabilir.
      Hypervisor, Intel VT-[xd] varsa çok daha kolay eklenebilir; yoksa emülasyona geri düşebilirsiniz. Çeviri tabanlı emülasyonun performansı çok iyidir.
      Interrupt handler’ları genelleştirip hızlandırma, race condition’lardan kaçınma ve lock-free kalıpları kullanma konusunda ustalaşmanız gerekir.
      x87 ve MMX dahil olmak üzere desteklenmeyen komutları yeniden yazmak veya trap’lemek de gerekir.
      Saf mikrokernel’lerin başarısız olmasının nedeni, birden çok kaynağı işlemsel biçimde sıralayıp yönetmenin getirdiği artan karmaşıklıktı.
      Mikrokernel mimarisinin teorik olarak güvenlik ve işletim açısından büyük avantajları var, ama saf haliyle yaygın biçimde yerleşmedi.