Un serveur Web performant et extensible avec Zig et Python

Linda Hamilton
Libérer: 2024-10-07 06:12:01
original
621 Les gens l'ont consulté

Préface

Je suis passionné par mon intérêt pour le développement de logiciels, en particulier le casse-tête de la création ergonomique de systèmes logiciels qui résolvent le plus grand nombre de problèmes tout en faisant le moins de compromis possible. J'aime aussi me considérer comme un développeur de systèmes, ce qui, selon la définition d'Andrew Kelley, signifie un développeur intéressé à comprendre complètement les systèmes avec lesquels il travaille. Dans ce blog, je partage avec vous mes idées pour résoudre le problème suivant : Construire une application d'entreprise full-stack fiable et performante. Tout un défi, n'est-ce pas ? Dans le blog, je me concentre sur la partie "serveur Web performant" - c'est là que je sens que je peux offrir une nouvelle perspective, car soit le reste est bien parcouru, soit je n'ai rien à ajouter.

Une mise en garde majeure : il n'y aura aucun exemple de code, je n'ai pas réellement testé cela. Oui, c'est un défaut majeur, mais la mise en œuvre de cela prendrait beaucoup de temps, ce que je n'ai pas, et entre publier un blog défectueux et ne pas le publier du tout, je suis resté fidèle au premier. Vous êtes prévenu.

A performant and extensible Web Server with Zig and Python

Et à partir de quelles pièces assemblerions-nous notre application ?

  • Une interface avec laquelle vous êtes à l'aise, mais si vous voulez des dépendances minimales, il y a Zig sous forme WASM HTMX.
  • Un serveur web Zig, étroitement intégré au noyau Linux. C'est la partie performante sur laquelle je vais me concentrer dans ce blog.
  • Un backend Python, intégré à Zig. C'est la partie complexe.
  • Intégration avec des systèmes d'exécution durables tels que Temporal et Flowable. Cela contribue à la fiabilité et ne sera pas abordé dans le blog.

Une fois nos outils choisis, commençons !

Les coroutines sont-elles surfaites de toute façon ?

Zig n'a pas de support linguistique pour les coroutines :( Et les coroutines sont ce avec quoi tout serveur Web performant est construit. Alors, cela ne sert à rien d'essayer ?

Attendez, mettons d'abord notre chapeau de programmeur système. Les coroutines ne sont pas une solution miracle, rien ne l'est. Quels sont les avantages et les inconvénients réels ?

Il est de notoriété publique que les coroutines (threads de l'espace utilisateur) sont plus légères et plus rapides. Mais de quelle manière exactement ? (les réponses ici sont en grande partie des spéculations, à prendre avec précaution et testez-les vous-même)

  • Ils commencent avec moins d'espace de pile par défaut (2 Ko au lieu de 4 Mo). Mais cela peut être ajusté manuellement.
  • Ils feraient mieux de coopérer avec le planificateur de l'espace utilisateur. Le planificateur du noyau étant préemptif, les tâches effectuées par les threads se voient attribuer des tranches de temps. Si les tâches réelles ne rentrent pas dans les tranches, du temps CPU est perdu. Contrairement, disons, aux Goroutines, qui intègrent autant de micro-tâches effectuées par différentes goroutines que possible dans la même tranche temporelle du thread du système d'exploitation.

A performant and extensible Web Server with Zig and Python

Le runtime Go, par exemple, multiplexe les goroutines sur les threads du système d'exploitation. Les threads partagent la table des pages, ainsi que d'autres ressources appartenant à un processus. Si nous introduisons l'isolation et l'affinité du processeur dans le mélange - les threads fonctionneront en permanence sur leurs cœurs de processeur respectifs, toutes les structures de données du système d'exploitation resteront en mémoire sans avoir besoin d'être échangées, le planificateur de l'espace utilisateur allouera du temps CPU aux goroutines avec précision, car il utilise le modèle multitâche coopératif. La compétition est-elle même possible ?

Les gains de performances sont obtenus en mettant de côté l'abstraction d'un thread au niveau du système d'exploitation et en la remplaçant par celle d'une goroutine. Mais rien n'est perdu dans la traduction ?

Pouvons-nous coopérer avec le noyau ?

Je soutiendrai que la "vraie" abstraction au niveau du système d'exploitation pour une unité d'exécution indépendante n'est même pas un thread - c'est en fait le processus du système d'exploitation. En fait, la distinction ici n'est pas aussi évidente : tout ce qui distingue les threads et les processus, ce sont les différentes valeurs PID et TID. En ce qui concerne les descripteurs de fichiers, la mémoire virtuelle, les gestionnaires de signaux, les ressources suivies, le fait qu'ils soient séparés pour l'enfant est spécifié dans les arguments de l'appel système "clone". Ainsi, j'utiliserai le terme « processus » pour désigner un thread d'exécution qui possède ses propres ressources système - principalement le temps processeur, la mémoire et les descripteurs de fichiers ouverts.

A performant and extensible Web Server with Zig and Python

이제 이것이 왜 중요한가요? 각 실행 단위에는 시스템 리소스에 대한 자체 수요가 있습니다. 각각의 복잡한 작업은 단위로 나눌 수 있으며, 각 작업은 메모리 및 CPU 시간과 같은 리소스에 대한 자체적이고 예측 가능한 요청을 할 수 있습니다. 그리고 하위 작업 트리에서 좀 더 일반적인 작업으로 갈수록 시스템 리소스 그래프는 긴 꼬리가 있는 종형 곡선을 형성합니다. 그리고 테일이 시스템 리소스 제한을 초과하지 않도록 하는 것은 귀하의 책임입니다. 하지만 그 작업은 어떻게 이루어지며 실제로 해당 한도가 초과되면 어떻게 되나요?

독립 작업에 단일 프로세스 모델과 여러 코루틴을 사용하는 경우 하나의 코루틴이 메모리 제한을 초과하면 메모리 사용량이 프로세스 수준에서 추적되기 때문에 전체 프로세스가 종료됩니다. 이것이 최선의 경우입니다. cgroup을 사용하는 경우(Pod당 cgroup이 있는 Kubernetes의 Pod에 자동으로 적용됨) 전체 cgroup이 종료됩니다. 신뢰할 수 있는 시스템을 만들려면 이 점을 고려해야 합니다. 그리고 CPU 시간은 어떻습니까? 우리 서비스가 동시에 많은 컴퓨팅 집약적 요청을 받게 되면 응답하지 않게 됩니다. 그런 다음 마감일, 취소, 재시도, 재시작이 이어집니다.

대부분의 주류 소프트웨어 스택에 대한 이러한 시나리오를 처리하는 유일한 현실적인 방법은 시스템에 "두꺼운"(종형 곡선의 꼬리에 사용되지 않는 일부 리소스)을 남겨두고 동시 요청 수를 제한하는 것입니다. 사용하지 않는 자원에. 그럼에도 불구하고 예외와 동일한 프로세스에 있는 "순진한" 요청을 포함하여 가끔씩 OOM이 종료되거나 응답하지 않게 됩니다. 이러한 절충안은 많은 사람들이 받아들일 수 있으며 실제로 소프트웨어 시스템에 충분히 도움이 됩니다. 하지만 더 잘할 수 있을까요?

동시성 모델

리소스 사용량은 프로세스별로 추적되므로 이상적으로는 예측 가능한 각각의 작은 실행 단위에 대해 새 프로세스를 생성하는 것이 좋습니다. 그런 다음 CPU 시간과 메모리에 대한 ulimit를 설정하면 됩니다. ulimit에는 소프트 제한과 하드 제한이 있어 소프트 제한에 도달하면 프로세스가 정상적으로 종료될 수 있으며, 버그로 인해 프로세스가 발생하지 않는 경우 하드 제한에 도달하면 강제로 종료됩니다. 불행하게도 Linux에서 새 프로세스를 생성하는 것은 느리며 요청당 새 프로세스를 생성하는 것은 많은 웹 프레임워크는 물론 Temporal과 같은 다른 시스템에서도 지원되지 않습니다. 또한 프로세스 전환은 비용이 더 많이 듭니다. 이는 CoW 및 CPU 고정으로 완화되지만 여전히 이상적이지는 않습니다. 불행하게도 장기 실행 프로세스는 피할 수 없는 현실입니다.

A performant and extensible Web Server with Zig and Python

단기 프로세스의 깔끔한 추상화에서 멀어질수록 우리가 스스로 처리해야 하는 OS 수준 작업이 더 많아집니다. 그러나 많은 실행 스레드 간에 IO를 일괄 처리하기 위해 io_uring을 사용하는 것과 같은 이점도 있습니다. 실제로 대규모 작업이 하위 작업으로 구성된 경우 개별 리소스 활용도에 정말로 관심이 있습니까? 프로파일링에만 사용됩니다. 그러나 대규모 작업의 경우 자원 종형 곡선의 꼬리를 관리(잘라내기)할 수 있다면 그것으로 충분할 것입니다. 따라서 동시에 처리하려는 요청만큼 많은 프로세스를 생성하고, 수명을 연장하고, 각 새 요청에 대한 ulimit를 간단히 재조정할 수 있습니다. 따라서 요청이 리소스 제약 조건을 초과하면 OS 신호를 받고 다른 요청에 영향을 주지 않고 정상적으로 종료될 수 있습니다. 또는 높은 리소스 사용량이 의도적인 경우 클라이언트에게 더 높은 리소스 할당량에 대해 비용을 지불하도록 지시할 수 있습니다. 제겐 꽤 좋은 것 같습니다.

그러나 요청별 코루틴 접근 방식에 비해 성능은 여전히 ​​저하됩니다. 첫째, 프로세스 메모리 테이블을 복사하는 데 비용이 많이 듭니다. 테이블에는 메모리 페이지에 대한 참조가 포함되어 있으므로 hugepage를 활용하여 복사할 데이터 크기를 제한할 수 있습니다. 이는 Zig와 같은 저수준 언어에서만 직접적으로 가능합니다. 또한 OS 수준 멀티태스킹은 선제적이며 비협조적이므로 항상 효율성이 떨어집니다. 아니면?

Linux를 사용한 협력적 멀티태스킹

스레드가 해당 작업 부분을 완료하면 CPU를 포기할 수 있게 해주는 syscall sched_yield가 있습니다. 상당히 협조적인 것 같습니다. 주어진 크기의 시간 조각을 요청하는 방법도 있을 수 있나요? 실제로는 SCHED_DEADLINE 일정 정책이 있습니다. 이는 요청된 CPU 시간 조각 동안 스레드가 중단 없이 실행된다는 것을 의미하는 실시간 정책입니다. 그러나 슬라이스가 오버런되면 선점이 시작되고 스레드가 교체되어 우선 순위가 낮아집니다. 그리고 슬라이스가 언더런되면 스레드는 sched_yield를 호출하여 조기 종료 신호를 보내 다른 스레드가 실행되도록 할 수 있습니다. 협력적이고 선제적인 모델이라는 두 가지 측면 모두에서 가장 좋은 것 같습니다.

A performant and extensible Web Server with Zig and Python

A limitation is the fact that a SCHED_DEADLINE thread cannot fork. This leaves us with two models for concurrency - either a process per request, which sets the deadline for itself, and runs an event loop for efficient IO, or a process that from the start spawns a thread for each micro-task, each of which sets its own deadline, and makes use of queues for communication with each other. The former is more straighforward, but requires an event loop in userspace, the latter makes more use of the kernel.

Both strategies achieve the same end as the coroutine model - by cooperating with the kernel, it is possible to have application tasks run with minimal interruptions.

Python as an embedded scripting language

This is all for the high-performance, low-latency, low-level side of things, where Zig shines. But when it comes to the actual business of the application, flexibility is much more valuable than latency. If a process involves real people signing off on documents - the latency of a computer is negligible. Also, despite suffering in performance, object oriented languages give the developer better primitives to model the domain of the business with. And on the furthest end of this, systems like Flowable and Camunda allow managerial and operations staff to program the business logic with more flexibility and a lower barrier of entry. Languages like Zig will not help with this, and only stand in your way.

A performant and extensible Web Server with Zig and Python

Python, on the other hand, is one of the most dynamic languages there are. Classes, objects - they are all dictionaries under the hood, and can be manipulated at runtime however you like. This has a performance penalty, but makes modeling the business with classes and objects and many clever tricks practical. Zig is the opposite of that - there are intentionally few clever tricks in Zig, giving you maximum control. Can we combine their powers by having them interoperate?

Indeed we can, due to having both support the C ABI. We can have the Python interpreter run from within the Zig process, and not as a separate process, reducing overhead in runtime cost and glue code. This further allows us to make use of Zig's custom allocators within Python - setting an arena for processing the individual request, thus reducing if not eliminating the overhead of a garbage collector, and setting a memory cap. A major limitation would be the CPython runtime spawning threads for garbage collection and IO, but I found no evidence that it does. We could hook Python into a custom event loop in Zig, with per-coroutine memory tracking, by making use of the "context" field in AbstractMemoryLoop. The possibilities are limitless.

Conclusion

We discussed the merits of concurrency, parallelism, and various forms of integration with the OS kernel. The exploration lacks benchmarks and code, which I hope it makes up for in the quality of ideas offered. Have you tried anything similar? What are your thoughts? Feedback welcome :)

Further reading

  • https://linux.die.net/man/2/clone
  • https://man7.org/linux/man-pages/man7/sched.7.html
  • https://man7.org/linux/man-pages/man2/sched_yield.2.html
  • https://rigtorp.se/low-latency-guide/
  • https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/
  • https://hadar.gr/2017/lightweight-goroutines

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

source:dev.to
Déclaration de ce site Web
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Derniers articles par auteur
Tutoriels populaires
Plus>
Derniers téléchargements
Plus>
effets Web
Code source du site Web
Matériel du site Web
Modèle frontal