C# Job System

Fatih Altuntaş
9 min readDec 14, 2022

Unity C# Job System, gelişmiş oyun performansı için Unity Engine ile etkileşime giren basit ve güvenli multithreaded kod yazmanıza olanak tanır. C# Job System’i, tüm platformlar için verimli makine kodu oluşturmayı kolaylaştıran bir mimari olan Unity Entity Component System (ECS) ile birlikte kullanabilirsiniz.

C# Job System Nasıl Çalışır?

Unity C# Job System’i, kullanıcıların Unity’nin geri kalanıyla iyi etkileşime giren ve doğru kodu yazmayı kolaylaştıran multithreaded kod yazmasına olanak tanır.

Multithreaded kod yazmak, yüksek performans avantajları sağlayabilir. Bunlar, frame rate’de önemli kazançlar içerir. Burst compiler’i C# Jobs kullanmak size gelişmiş kod oluşturma kalitesi sağlar ve bu da mobil cihazlarda pil tüketiminde önemli ölçüde azalma sağlar.

C# Job System’in önemli bir özelliği, Unity’nin dahili olarak kullandıklarıyla (Unity’nin native job system’i) entegrelidir. Kullanıcı tarafından yazılan kod ve Unity, worker thread’leri paylaşır. Bu işbirliği, CPU kaynakları için çekişmeye neden olacak CPU core’larından daha fazla iş parçacığı oluşturmayı önler.

Daha fazla bilgi için Unity at GDC — Job System & Entity Component System.

Multithreading Nedir?

Single-threaded bir bilgisayar sisteminde, bir seferde bir talimat girer ve bir seferde bir sonuç çıkar. Programları yükleme ve tamamlama süresi, CPU’nun yapması gereken iş miktarına bağlıdır.

Multithreading, bir CPU’nun birden çok thread’de aynı anda birçok iş parçacığını işleme yeteneğinden yararlanan bir programlama türüdür. Arka arkaya yürütülen görevler veya talimatlar yerine, aynı anda çalışırlar.

Varsayılan olarak bir programın başlangıcında bir thread çalışır. Bu main thread dir. Main thread görevleri yerine getirmek yeni thread ler oluşturur. Bu yeni thread ler birbirine paralel olarak çalışır ve genellikle tamamlandıktan sonra sonuçlarını main thread ile senkronize eder.

Uzun süre çalışan birkaç göreviniz varsa, bu multithreading yaklaşımı işe yarar. Ancak, oyun geliştirme kodu genellikle aynı anda yürütülecek birçok küçük talimat içerir. Her biri için bir thread oluşturursanız, ömrü kısa olan birçok thread elde edebilirsiniz. Bu, CPU’nuzun ve işletim sisteminizin işlem kapasitesinin sınırlarını zorlayabilir.

Bir pool of threads e sahip olarak thread ömrü sorununu azaltmak mümkündür. Ancak, bir thread pool kullansanız bile, aynı anda çok sayıda etkin thread olması muhtemeldir. CPU core sayısında daha fazla thread e sahip olmak, thread lerin CPU kaynakları için birbirleriyle rekabet etmesine yol açar, bu da sonuç olarak sık sık context switching e neden olur. Context switching, bir thread in durumunu kısmen kaydetme, ardından başka bir thread üzerinde çalışma ve daha sonra işlemeye devam etmek için ilk thread i yeniden oluşturma işlemidir. Context switching yoğun kaynak kullanır, bu nedenle mümkün olan her yerde buna ihtiyaç duymadan kaçınmalısınız.

Job System Nedir?

Job system, thread ler yerine job lar oluşturarak multithreaded kodu yönetir

Job system, birden çok çekirdekte bir grup worker thread leri yönetir. Context switching i önlemek için genellikle logical CPU core başına bir worker thread e sahiptir (ancak bazı çekirdekleri işletim sistemi veya diğer özel uygulamalar için ayırabilir).

Job system, işleri yürütülmek üzere bir job queue ya koyar. Job system deki worker thread ler, job queue dan öğeler alır ve bunları execute eder. Job system dependencies yönetir ve job ların uygun sırada execute edilmesini sağlar.

Job Nedir?

Job, belirli bir görevi yapan küçük bir iş birimidir. Bir job, bir metodun nasıl davrandığına benzer şekilde parametreleri alır ve veriler üzerinde çalışır. Job lar bağımsız olabilir veya çalıştırılmadan önce tamamlanması diğer job lara bağlı olabilir.

Job Dependencies Nedir?

Oyun geliştirme gibi karmaşık sistemlerde, her job ın bağımsız olması pek olası değildir. Bir job genellikle verileri bir sonraki job için hazırlar. Jobs, bu işi yapmak için bağımlılıkların farkındadır ve bunları destekler. JobA’nın jobB’ye bağımlılığı varsa, job system jobA’nın jobB tamamlanana kadar yürütülmeye başlamamasını sağlar.

C# Job System’deki Güvenlik Sistemi

Race conditions

Multithreaded kod yazarken, race conditions için her zaman bir risk vardır. Bir işlemin çıktısı, kontrolü dışındaki başka bir işlemin zamanlamasına bağlı olduğunda bir (race condition)yarış durumu oluşur.

Bir yarış durumu her zaman bir hata değildir, ancak deterministik olmayan bir davranış kaynağıdır. Bir race condition bir hataya neden olduğunda, sorunun kaynağını bulmak zor olabilir çünkü bu zamanlamaya bağlıdır, bu nedenle sorunu yalnızca nadir durumlarda yeniden oluşturabilirsiniz. Debugging, sorunun kaybolmasına neden olabilir, çünkü breakpoints ve logging kaydı tek tek thread lerin zamanlamasını değiştirebilir. Race conditions, multithreaded kod yazarken en önemli zorluğu ortaya çıkarır.

Güvenlik sistemi

Multithreaded kod yazmayı kolaylaştırmak için Unity C# İş Sistemi tüm olası race conditionların algılar ve sizi bunların neden olabileceği hatalardan korur.

Örneğin: C# Job System, kontrol threaddaki kodunuzdan bir job’a bir data referansı gönderirse, kontrol thread, job ona yazarken aynı zamanda dataları okuyup okumadığını doğrulayamaz.

C# Job System bunu, her job’a, kontrol thread’daki verileri referans vermek yerine üzerinde çalışması gereken verilerin bir kopyasını göndererek çözer. Bu kopya, race condition’ı ortadan kaldıran verileri izole eder.

Job System yalnızca bittable data türündeki verileri kopyalar, yani sadece bu türdeki verilere erişebilir. Bu türler, managed ve native kod arasında geçtiğinde dönüştürmeye ihtiyaç duymazlar.

Job System, blittable türleri memcpy ile kopyalayabilir ve verileri Unity’nin managed ve native bölümleri arasında aktarabilir. İşleri planlarken (scheduling jobs) verileri yerel belleğe (native memory) koymak için memcpy kullanır ve işleri yürütürken (executing jobs) yönetilen tarafa (managed side) bu kopyaya erişim sağlar.

Native Container

Güvenlik sisteminin verileri kopyalama sürecinin dezavantajı, aynı zamanda her bir kopyadaki bir işin sonuçlarını (results) da izole etmesidir. Bu sınırlamanın üstesinden gelmek için, sonuçları NativeContainer adı verilen bir tür paylaşılan bellekte (shared memory) saklamanız gerekir.

NativeContainer, yerel bellek için güvenli bir C# sarmalayıcı (wrapper) sağlayan, yönetilen bir değer türüdür (managed value type). Bir işin bir kopyayla çalışmak yerine ana iş parçacığıyla paylaşılan verilere erişmesine izin verir.

NativeContainer Türleri

Unity, NativeArray adlı bir NativeContainer ile birlikte gelir. NativeArray öğesinin bir alt kümesini belirli bir konumdan belirli bir uzunluğa almak için NativeArray öğesini NativeSlice ile de değiştirebilirsiniz.

Not: Entity Component System (ECS) paketi, Unity.Collections namespace’i diğer NativeContainer türlerini içerecek şekilde genişletir:

  • NativeList — yeniden boyutlandırılabilir (resizable) bir NativeArray.
  • NativeHashMap — key ve value çiftleri (dictionary).
  • NativeMultiHashMap — key başına birden çok value.
  • NativeQueue — ilk giren ilk çıkar (FIFO) kuyruğu.

NativeContainer ve Güvenlik Sistemi

Güvenlik sistemi, tüm NativeContainer türlerinde yerleşiktir. Herhangi bir NativeContainer’a ne okuduğunu ve yazdığını izler.

Not: NativeContainer türlerindeki tüm güvenlik kontrolleri (out of bounds checks, deallocation checks, and race condition checks gibi) yalnızca Unity Editor ve Play modunda kullanılabilir.

Bu güvenlik sisteminin bir parçası DisposeSentinel ve AtomicSafetyHandle’dır. DisposeSentinel bellek sızıntılarını (memory leaks) algılar ve belleğinizi doğru şekilde boşaltmadıysanız size bir hata verir. Bellek sızıntısı hatasının tetiklenmesi, sızıntı meydana geldikten çok sonra gerçekleşir.

Bir NativeContainer’ın sahipliğini kodda aktarmak için AtomicSafetyHandle’ı kullanın. Örneğin, iki zamanlanmış iş (scheduling jobs) aynı NativeArray’e yazıyorsa, güvenlik sistemi, sorunun nedenini ve nasıl çözüleceğini açıklayan bir hata exception’ı atar. Hatalı işi planladığınızda güvenlik sistemi bu exception’ı atar.

Bu durumda, bağımlılığı (dependency) olan bir iş planlayabilirsiniz. İlk iş NativeContainer’a yazabilir ve yürütmeyi (executing) bitirdiğinde sonraki iş aynı NativeContainer’a güvenle okuyup yazabilir. Ana iş parçacığından (main thread) verilere erişirken de okuma ve yazma kısıtlamaları geçerlidir. Güvenlik sistemi, birden fazla işin aynı verilerden paralel olarak okunmasına izin verir.

Varsayılan olarak, bir işin bir NativeContainer’a erişimi olduğunda, hem okuma hem de yazma erişimi vardır. Bu yapılandırma performansı yavaşlatabilir. Job System, NativeContainer’a yazma erişimi olan bir işi, ona yazan başka bir işle aynı anda programlamanıza izin vermez.

Bir işin NativeContainer’a yazması gerekmiyorsa NativeContainer’ı [ReadOnly] attribute ile aşağıdaki gibi işaretleyin:

[ReadOnly] 
public NativeArray<int> input;

Yukarıdaki örnekte, işi, ilk NativeArray’e salt okunur erişimi olan diğer işlerle aynı anda yürütebilirsiniz.

Not: Bir işin içinden statik verilere erişmeye karşı herhangi bir koruma yoktur. Statik verilere erişim, tüm güvenlik sistemlerini atlatır ve Unity’yi çökertebilir.

NativeContainer Allocator

NativeContainer oluştururken, ihtiyacınız olan bellek ayırma türünü (memory allocation type) belirtmelisiniz. Allocation type, işin çalıştığı süreye bağlıdır. Bu şekilde, her durumda mümkün olan en iyi performansı elde etmek için allocation’ı özelleştirebilirsiniz.

NativeContainer’ın memory allocation ve release için üç Allocator türü vardır. Bir NativeContainer instantiate ederken uygun olanı belirtmelisiniz.

  • Allocator.Temp en hızlı allocation’dır. Ömrü bir frame veya daha az olan allocation’lar için kullanın. Ancak, NativeContainer allocation’larını işlere göndermek (pass) etmek için Temp’i kullanamazsınız.
  • Allocator.TempJob, Temp’ten daha yavaş bir allocation ancak Persistent’tan daha hızlıdır. Dört frame’lik bir kullanım ömrü içinde iş parçacığı güvenli allocation’lar için kullanın. Önemli: Bu tür allocation’ı dört frame içinde Dispose etmemiz gerekir, aksi takdirde konsol native koddan oluşturulan bir uyarı yazdırır. Çoğu küçük iş, bu NativeContainer allocation türünü kullanır.
  • Allocator.Persistent en yavaş allocator’dır, ancak ihtiyacınız olduğu sürece ve gerekirse uygulamanın ömrü boyunca sürebilir. Malloc’a doğrudan çağrı için bir wrapper’dır. Daha uzun işler bu NativeContainer allocation türünü kullanabilir. Persistent’ı performansın önemli olduğu yerlerde kullanmayın.

Örneğin:

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

Not: Yukarıdaki örnekteki 1 sayısı NativeArray’in boyutunu gösterir. Bu örnekte, result’da yalnızca bir veri parçasını depoladığı için yalnızca bir dizi öğesi vardır.

Jobs Oluşturma

Unity’de bir iş oluşturmak için IJob interface’ini uygulamanız gerekir. IJob, çalışmakta olan diğer işlere paralel olarak çalışan tek bir işi zamanlamanıza (schedule) olanak tanır.

Not: “job”, Unity’de IJob interface’ini uygulayan herhangi bir yapı için ortak bir terimdir.

Bir iş oluşturmak için yapmanız gerekenler:

İş yürütülürken Execute yöntemi tek bir çekirdekte (single cıre) bir kez çalışır.

Not: İşinizi tasarlarken, NativeContainer durumu dışında verilerin kopyaları üzerinde çalıştıklarını unutmayın. Bu nedenle, kontrol thread’daki bir işten verilere erişmenin tek yolu bir NativeContainer’a yazmaktır.

Basit bir iş tanımı örneği

Scheduling Jobs

Bir işi planlamak için şunları yapmalısınız:

  • İşi somutlaştırın (instantiate).
  • İşin verilerini doldurun.
  • Schedule metodunu çağırın.

Schedule, işi uygun zamanda yürütülmek (executing) üzere iş kuyruğuna (job queue) koyar. Bir kez planlandığında, bir işi yarıda kesemezsiniz.

Not: İşlerin içinden Schedule’ı çağıramazsın.

Scheduling job örneği

JobHandle ve Bağımlılıklar (dependencies)

Bir işin Schedule metodunu çağırdığınızda, bir JobHandle döndürür. Diğer işler için bir bağımlılık (dependency) olarak kodunuzda bir JobHandle kullanabilirsiniz. Bir iş başka bir işin sonuçlarına bağlıysa, birinci işin JobHandle’ını ikinci işin Schedule yöntemine parametre olarak şu şekilde iletebilirsiniz:

JobHandle firstJobHandle = firstJob.Schedule(); secondJob.Schedule(firstJobHandle);

Not: Bir işin tüm bağımlılıkları, işin kendisiyle aynı kontrol iş parçacığında (control thread) planlanmalıdır.

Bağımlılıkları birleştirmek

Bir işin birçok bağımlılığı varsa, bunları birleştirmek için JobHandle.CombineDependencies metodunu kullanabilirsiniz. CombineDependencies, bunları Schedule yöntemine aktarmanıza olanak tanır.

NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
JobHandle jh = JobHandle.CombineDependencies(handles);

Jobs’ı kontrol thread’da beklemek

job’ın execute’unu bitirmesini kontrol therad’de beklemeye zorlamak için JobHandle’ı kullanın. Bunu yapmak için JobHandle’da Complete metodunu çağırın. Bu noktada, kontrol thread’inin job’ın kullandığı NativeContainer’a güvenle erişebileceğini bilirsiniz.

Not: Jobs, schedule ettiğinizde execute etmeye başlamaz. Kontrol thread’de job’ı bekliyorsanız ve job’ın kullandığı NativeContainer verilerine erişmeniz gerekiyorsa, JobHandle.Complete metodunu çağırabilirsiniz. Bu metod, job’ları bellek önbelleğinden (memory cache) temizler ve execute sürecini başlatır. Bir JobHandle’da Complete çağrısı, o job’ın NativeContainer türlerinin sahipliğini kontrol thread’ine döndürür. Kontrol thread’dinde bu NativeContainer türlerine güvenli bir şekilde erişmek için JobHandle’da Complete’i çağırmanız gerekir. Bir job dependency’den olan JobHandle’da Complete’i çağırarak kontrol thread’e sahipliği iade etmek de mümkündür. Örneğin, JobA’da Complete’i veya jobA’ya bağlı olarak JobB’de Complete’i çağırabilirsiniz. Her ikisi de, JobA tarafından kullanılan NativeContainer türlerinin, Complete çağrısından sonra kontrol thread’de güvenli erişim sağlamasına neden olur.

Aksi takdirde, verilere erişmeniz gerekmiyorsa, batch’i açıkça temizlemeniz gerekir. Bunu yapmak için JobHandle.ScheduleBatchedJobs statik metodunu çağırın. Bu metodu çağırmanın performansı olumsuz etkileyebileceğini unutmayın.

Birden çok jobs ve dependencies

Job

Main thread

ParallelFor jobs

Jobs schedule edilirken, bir görevi yapan yalnızca bir job olabilir. Bir oyunda, aynı işlemi çok sayıda nesne üzerinde gerçekleştirme isteği yaygındır. Bunu yapmak için IJobParallelFor adlı ayrı bir job türü vardır.

Not: Bir “ParallelFor” job’ı, Unity’de IJobParallelFor interface’ini uygulayan herhangi bir struct için genel bir terimdir.

Bir ParallelFor job’ı, veri kaynağı olarak hareket etmek için bir NativeArray data’sı kullanır. ParallelFor jobs birden çok çekirdekte (multiple core) çalışır. Çekirdek başına, her biri iş yükünün bir alt kümesini işleyen bir job vardır. IJobParallelFor, IJob gibi davranır, ancak tek bir Execute yöntemi yerine, veri kaynağındaki öğe başına bir kez Execute yöntemini çağırır.
Execute yönteminde bir integer parametresi vardır. Bu index, job uygulamasında veri kaynağının tek bir öğesine erişmek ve bu öğe üzerinde çalışmak içindir.

ParallelFor Job tanımına bir örnek:

Scheduling ParallelFor jobs

ParallelFor job’ları zamanlarken (scheduling), ayırdığınız NativeArray veri kaynağının uzunluğunu belirtmelisiniz. Unity C# Job System, yapıda birden fazla NativeArray varsa, veri kaynağı olarak hangi NativeArray’i kullanmak istediğinizi bilemez. NativeArray uzunluğu ayrıca C# Job System’de kaç Execute metodunun çalışacağını söyler.

Arka planda , ParallelFor job’ların zamanlaması daha karmaşıktır. ParallelFor job’larını planlarken, C# Job System’i, çekirdekler arasında dağıtmak için işi gruplara(batch) ayırır. Her batch, Execute metodlarının bir alt kümesini içerir. C# Job System’i daha sonra Unity’nin native job sisteminde CPU çekirdeği başına bir job planlar ve bu native job’ı tamamlaması için bazı batch’lerden geçirir.

Batch’leri çekirdekler arasında bölen ParallelFor job
Batch’leri çekirdekler arasında bölen ParallelFor job

Native job, batch’lerini diğerlerinden önce tamamladığında, diğer native job’lardan kalan batch’leri çalar. Cache locality’i sağlamak için bir seferde native job’ın kalan batch’lerinin yalnızca yarısını çalar.

Süreci optimize etmek için bir Batch count belirtmeniz gerekir. Batch sayısı, kaç tane job aldığınızı ve thread’ler arasında job’ın yeniden dağılımının ne kadar ayrıntılı olduğunu kontrol eder. 1 gibi düşük bir batch sayısına sahip olmak, thread’ler arasında daha eşit bir job dağılımı sağlar. Batch sayısını düşük tutmak bazı ek yüklerli beraberinde getirir, bu nedenle bazen batch sayısını artırmak daha iyidir. 1'den başlamak ve batch sayısını ihmal edilebilir performans kazanımları elde edene kadar artırmak geçerli bir stratejidir.

Örnek:

Job

Main thread

C# Job System ipuçları ve sorun giderme yöntemleri

Unity C# Job System’i kullanırken aşağıdakilere uyduğunuzdan emin olun:

Job’dan static verilere erişme!

Bir job’dan statik verilere erişmek, tüm güvenlik sistemlerini engeller. Yanlış verilere erişirseniz, genellikle beklenmedik şekillerde Unity’yi çökertebilirsiniz. Örneğin, MonoBehaviour’a erişim, domain reload’da çökmelere neden olabilir.

Not: Bu risk nedeniyle, Unity’nin gelecekteki sürümleri, static analysis kullanan job’lardan global değişken erişimini engelleyecektir. Bir job’ın içindeki statik verilere erişirseniz, Unity’nin gelecekteki sürümlerinde kodunuz kırılır.

Scheduled batch’leri temizleme

Job’ların yürütülmeye(execute) başlamasını istediğinizde, scheduled batch’i JobHandle.ScheduleBatchedJobs ile temizleyebilirsiniz. Bu yöntemi çağırmanın performansı olumsuz etkileyebileceğini unutmayın. Batch’i temizlememek, kontrol thread sonucu bekleyene kadar schedule’ı geciktirir. Diğer tüm durumlarda, yürütme sürecini başlatmak için JobHandle.Complete öğesini kullanın.

Not: Entity Component System’de (ECS) batch, sizin için dolaylı olarak temizlenir, bu nedenle JobHandle.ScheduleBatchedJobs’u çağırmak gerekli değildir.

NativeContainer içeriğini güncellemeye çalışmayın

Referans dönüşlerinin (ref returns) olmaması nedeniyle, NativeContainer’ın içeriğini doğrudan değiştirmek mümkün değildir. Örneğin, nativeArray[0]++; yazmakla var temp = nativeArray[0]; temp++; yazmak aynı şeydir ki bu, nativeArray içindeki değeri güncellemez.

Bunun yerine, dizindeki verileri yerel bir geçici kopyaya kopyalamanız, bu kopyayı değiştirmeniz ve şu şekilde geri kaydetmeniz gerekir:

MyStruct temp = myNativeArray[i];
temp.memberVariable = 0;
myNativeArray[i] = temp;

--

--