Savunma tarafında gerçek zamanlı tespit ve takip için C++ yazıyorum: bir görünür kanal, bir termal kanal, bir çıkarım aşaması ve bir Qt arayüzü, hepsi gömülü NVIDIA donanımında. Hiçbiri tek thread’le çalışmıyor. Ve takip sisteminde kovaladığım çirkin hataların neredeyse tamamı iki thread’in arasındaki boşlukta yaşıyordu. Bu yazı, gerçekten ısıran şeylerin kısa listesi.

Şekil: kendi çizimim.
Thread’leri çorba değil, hat olarak kur
Hayatta kalan tasarım sıkıcı: aşama başına bir thread. Sensör başına yakalama, ön işleme, çıkarım, takip, görüntüleme. Aşamalar kendi verisine sahip ve birbirleriyle kuyruklar üzerinden konuşuyor. Hiçbir thread başka bir thread’in durumuna elini sokmuyor, asla.
Alternatifi, bir şey yavaş hissettirdiğinde thread açmak. Bu, “şu anda bu buffer’ın sahibi kim” sorusuna cevap veremediğin kod üretir. O soruya cevap veremiyorsan elinde bir threading tasarımı yok, iyi niyetli bir race condition var demektir.
Arayüz özel bir kural hak ediyor: Qt thread’i yalnızca tüketir. Bitmiş kareleri ve bitmiş takip durumlarını alır, çizer ve hattı asla bekletmez. Arayüz tarafını Qt ile operatör arayüzü yazısında anlattım.
Sınırlı kuyruklar ve kare atma cesareti
İki aşamayı sınırsız bir kuyrukla bağlarsan bir gecikme bombası kurmuşsundur. Tüketici üreticiden birazcık bile yavaşsa kuyruk büyür, sessizce, ve operatör sistem her şey yolunda derken saniyeler öncesinin görüntüsüne bakıyor olur.
Gerçek zaman “her kareyi hızlı işle” demek değil. “Her zaman en yeni kareye göre davran” demek. O yüzden aşamalar arası kuyruklar sınırlı ve küçük, bir iki slot, ve kuyruk dolduğunda en eski kare atılıyor. Kare atmak ilk yazdığında yanlış hissettirir. Doğru karardır: hareket modeli olan bir takipçi eksik bir kareyi rahatça köprüler ama bayat bir kareyi görmemiş yapamaz.
Gerçekten ısıran hatalar
Yaralarımın çoğunu dört kalıp kapatıyor:
Sığ kopyalar. cv::Mat kopyaları alttaki buffer’ı referans sayacıyla
paylaşır. Birini thread sınırından umursamadan geçirirsen iki thread artık aynı
piksellere yazıyordur. Sınırda clone yap ya da düzgün bir buffer havuzu kullan.
Kilit sırası. İki yerde farklı sırayla alınan iki mutex, yük altında patlamayı bekleyen bir deadlock’tur. Kuyruk başına tek kilit, asla iç içe değil; problemin çoğu böyle kayboluyor.
Condition variable’lar. Uyanmalar sahte olabilir, notify’lar kaçabilir. Her zaman bir predicate ile bekle. Predicate’siz sürüm masanda çalışır, sahada çöker.
False sharing. Aynı önbellek satırına denk gelmiş, iki thread’in güncellediği iki sayaç sürünür. Bu hata hiçbir şeyi çökertmez, bütçeyi sessizce yer; gömülü bir kartta bu daha kötüsüdür.
Thread’ler, OpenMP ve CUDA aynı bütçeyi paylaşır
Thread’ler hatta yapısını verir. Aşamanın içinde sıcak döngüleri OpenMP paralelleştirir. Ağır matematik GPU’ya aittir. Bu üçü iyi birleşir ama aynı çekirdek ve bellek bant genişliği havuzundan çeker: OpenMP’ye verdiğin her çekirdek, yakalama ve takip thread’lerinin alamadığı bir çekirdektir. Jetson üzerinde bu gerilim teorik değil; ön işlemeyle kodlayıcı aynı kümeyi ilk kez kapıştığında hissedersin. CPU tarafındaki iki modeli OpenMP ile CUDA farkı yazısında karşılaştırdım.
Artık her hatta şu kurallarla başlıyorum: her buffer’ın tam olarak bir sahibi var, veri thread’ler arasında ya sınırlı bir kuyrukla geçer ya da hiç geçmez, ve uygulama kodunda neyi koruduğunu söyleyen bir yorum olmadan çıplak mutex yok. Zor kısım thread’ler değil. Sahiplik.
Kaynakça
- Wikipedia, Üretici-tüketici problemi: https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem
- Wikipedia, Race condition: https://en.wikipedia.org/wiki/Race_condition
- Wikipedia, False sharing: https://en.wikipedia.org/wiki/False_sharing
- OpenCV, cv::Mat referansı (bellek yönetimi ve referans sayacı): https://docs.opencv.org/4.x/d3/d63/classcv_1_1Mat.html
- NVIDIA, CUDA C++ Best Practices Guide (asenkron aktarım ve örtüştürme): https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/