PythonProgramlamaPython Backend Geliştiricisi

**Python**'ın `contextvars` modülü, tek bir OS işlemci üzerinde çoklu görevlerin için ayrık mantıksal yürütme bağlamlarını nasıl korur?

Hintsage yapay zeka asistanı ile mülakatları geçin

Sorunun Cevabı

Sorunun Geçmişi

Python 3.7'den önce, geliştiriciler kullanıcı oturumları veya veritabanı bağlantıları gibi isteğe özel verileri depolamak için yalnızca threading.local()'a güveniyorlardı. Ancak, asyncio’nun yaygınlaşması temel bir hatayı ortaya çıkardı: iş parçacığına özgü depolama, aynı olay döngüsü iş parçacığında çalışan tüm korotinler tarafından paylaşılır. Bir asenkron görev kontrolü bıraktığında, başka bir görev yanlışlıkla ilk görevin sözde izole durumunu erişebilir veya değiştirebilir, bu da güvenlik açıklarına ve veri bozulmasına yol açar. PEP 567, OS iş parçacıklarından bağımsız mantıksal yürütme bağlamı izolasyonu sağlamak için contextvars'ı tanıttı ve bu kavramı C# ve Erlang'daki benzer mekanizmalardan modelledi.

Sorun

Senkran Python'da, her HTTP isteği tipik olarak kendi iş parçacığında çalışır, bu nedenle threading.local() isteğe özel bağlamı depolamak için yeterlidir. Asenkron mimarilerde, binlerce eşzamanlı istek tek bir olay döngüsü tarafından yönetilen bir iş parçacığına çoklanabilir. Eğer iki asenkron görev yürütmeyi iç içe geçirirse—biri await'te duraksarken diğeri devam ederse—aynı iş parçacığına özgü sözlük paylaşılır. Görev geçişlerinde bağlamı anlık görüntü alıp geri yükleyecek bir mekanizma olmadan, global durum mantıksal olarak ayrı işlemler arasında sızar. Bu, Görev A'nın kimlik doğrulama belirtecinin Görev B'ye görünür hale geldiği veya veritabanı işlem sınırlarının ilgisiz istekler arasında bulanıklaştığı yarış koşulları yaratır.

Çözüm

Python, iş parçacığı durumunda saklanan değişmez bir haritaya anahtar olarak ContextVar'ı uygular. Her asenkron görev, paylaşılmış durumu değiştirmek yerine yeni sürümler oluşturan kalıcı bir veri yapısı olan kendi Context nesnesine bir referans tutar. asyncio bir görevi await'te askıya aldığında, mevcut bağlamı yakalar; yeniden başlarken bu bağlamı geri yükler, böylece ContextVar.get() o belirli göreve bağlı değeri döndürür, oysaki OS iş parçacıkları değişmiş olabilir. Bu kopyalama-yazma semantiği, kilitlenme aşırılığı olmadan izolasyonu garanti eder.

import contextvars import asyncio request_id = contextvars.ContextVar('request_id', default='unknown') async def process_task(task_name): # Bu spesifik görev bağlamı için değeri ayarla token = request_id.set(task_name) try: await asyncio.sleep(0.01) # Kontrolü bırak, diğer görevler çalışabilir current = request_id.get() print(f"Görev {task_name} okuyor: {current}") finally: request_id.reset(token) # Önceki bağlamı geri yükle async def main(): # Aynı iş parçacığında iki görevi eşzamanlı çalıştır await asyncio.gather(process_task('Alpha'), process_task('Beta')) asyncio.run(main())

Hayattan Bir Durum

Yüksek verimli bir API geçidi inşa eden bir ekip, iş parçacıklı Flask uygulamasından asenkron FastAPI hizmetine geçti. Yük altında, threading.local() içinde mevcut kullanıcıyı saklayan kimlik doğrulama ara yazılımlarının, rastgele bir Kullanıcı A'nın kimliğini Kullanıcı B'nin isteklerine atadığını keşfettiler. İlk hata ayıklama, yarış koşullarını öne sürdü, ancak günlükler atamaların tek işçi konuşlandırmaları sırasında bile gerçekleştiğini gösterdi. Temel neden, asyncio'nun işbirlikçi çoklu görev yapısıydı; bu, bir istek işleyicisinin bir veritabanı çağrısında duraksaması sırasında başka bir işleyicinin aynı iş parçacığında çalışmasına ve iş parçacığına özgü depolamayı devralmasına izin veriyordu.

Ekip, threading.get_ident() ile global bir sözlüğe anahtar koymaya çalıştı, bunun istekleri izole edeceğini varsayarak. Bu yaklaşım, dış bağımlılıklar eklemeden eski kod tabanından basit bir geçiş sunmuştu. Ancak, uvicorn ile asyncio altında, aynı iş parçacığı birden çok isteği ardışık olarak işleyerek, sözlüğün önceki isteklere ait bayat verileri saklamasına neden oldu ve ilgisiz istekler arasında yetki yükseltme hatalarına yol açtı.

Medyar katmanından veritabanı katmanına kadar tüm çağrı yığınında context sözlük parametresini kabul edecek şekilde her işlev imzasını yeniden düzenlediler. Bu açık veri akışı, gizli durumu ortadan kaldırdı ve senkron ve asenkron sınırları boyunca çalıştı. Ne yazık ki, bu, binlerce işlev üzerinde kapsamlı bir yeniden düzenleme gerektirdi ve global yapılandırma nesnelerini bekleyen üçüncü taraf kitaplıkların entegrasyonunu bozdu, ayrıca ortaya çıkan kodun sıkılaştırılması bakım yükünü ve geliştirici hatası riskini önemli ölçüde artırdı.

Ekip, kimlik doğrulanan kullanıcı nesnesini saklamak için contextvars.ContextVar'ı benimsedi; ara yazılım isteğe girdiğinde değişkeni ayarladı ve aşağıdaki işlevler bunu .get() aracılığıyla erişti, böylece işlev imzalarını kirletmeden kullanabildi. Bu yaklaşım, büyük bir mimari yenileme gerektirmedi ve eşzamanlı görevler arasında otomatik izolasyon sağladı; ancak bellek sızıntılarını önlemek için reset() belirteçlerinin dikkatli yönetimini gerektirdi. Ayrıca, hata ayıklama daha zor hale geldi çünkü durum yürütme bağlamında gizliydi ve yığın izlerinde görünmeyen bir durumdaydı.

Sonuçta, prototipleme gösterdiği için contextvars'ı seçtiler; bu sadece ara katmanına değişiklikler gerektiriyordu ve açık bağlam geçişinin büyük yeniden yapılandırmasıyla ilişkilendirilmemişti. Belirteçlerin sıfırlanmasını sağlamak için istek işleyicilerini try/finally blokları içinde sarmaladılar, böylece bellek sızıntılarını önlemek ve temiz işlev imzalarını korumak sağlandı. Geçit artık her işçi başına 50,000 eşzamanlı bağlantıyı işliyor ve istekler arası veri sızıntısını önlüyor, ayrıca OS iş parçacığı sayısını her örnekte 100'den 4'e düşürerek bellek kullanımını %80 oranında azalttı ve genel verimliliği %300 artırdı.

Adayların Sıklıkla Atladığı Noktalar

threading.local() asenkron kodda neden başarısız oluyor ama iş parçacıklı kodda çalışıyor?

İş parçacıklı Python'da, işletim sistemi iş parçacıklarını önceden belirler ve her biri kendi C yığınını ve PyThreadState yapısını korur. threading.local() değişkenleri bu işletim sistemi düzeyindeki iş parçacığı kimliğine eşler ve böylece izolasyonu garanti eder. asyncio'da, olay döngüsü iş parçacığında görevleri işbirlikçi bir şekilde zamanlar; bir görev kontrolü bırakıldığında, döngü hemen aynı iş parçacığında başka bir görevi çalıştırır ve PyThreadState değiştirilmez. Sonuç olarak, threading.local() her iki görev için aynı anahtarı görür ve durum sızıntısına neden olur. Contextvars, görev geçişleri sırasında olay döngüsünün değiştirdiği PyThreadState içinde bağlam eşlemeleri yığını tutarak bu durumu çözer ve OS iş parçacıklarından bağımsız mantıksal bir izolasyon yaratır.

Bir ContextVar belirteci sıfırlamayı unutursanız ne olur?

ContextVar.set() önceki durumu temsil eden bir Token nesnesi döndürür; bu, önceki değeri geri yüklemek için reset()'e iletilmelidir. Eğer bunu ihmal ederseniz—örneğin, try/finally bloğunu atlayarak—değişken, amaçlanan kapsamın ötesinde değerini korur. Uzun süreli asenkron sunucularda, bu, bağlam zincirinde eski istek bağlamlarının birikmesine neden olan bir bellek sızıntısı oluşturur ve eğer bağlam düzgün bir şekilde geri yüklenmezse, bu iş parçacığındaki sonraki görevler bayat değerler devralabilir. Geleneksel yığın değişkenlerinden farklı olarak, bağlam değişkenleri yürütme bağlamında açıkça sıfırlanana veya görev sona erene kadar kalır; bu nedenle temizleme zorunlu hale gelir.

Bağlam değişkenleri çocuk görevler ve iş parçacıklarına nasıl yayılarak ulaşır?

asyncio.create_task() kullanıldığında, çocuk görev otomatik olarak ebeveynin mevcut bağlamının bir kopyasını alır ve bağlam değişkenlerinin asenkron çağrı grafiği boyunca doğal olarak akmasını sağlar. Ancak, concurrent.futures.ThreadPoolExecutor veya loop.run_in_executor() kullanıldığında, çağrılabilir farklı bir OS iş parçacığında çalışır ve varsayılan olarak boş bir bağlamla başlar. Adaylar sıklıkla bağlamların iş parçacığı sınırlarında yayıldığını varsayarlar; oysaki contextvars, mantıksal asenkron bağlama özeldir. Değerlerin iş parçacıklarına yayılmasını sağlamak için bağlamı açıkça contextvars.copy_context() ile yakalamalı ve işlevi içinde context.run() ile çalıştırmalısınız; ya da değişkenleri argüman olarak manuel olarak geçirmelisiniz.