Django 비동기 지원을 사용해야 합니까?

Nov 28 2022
나처럼 빠른 웹 개발을 위한 완전한 기능을 갖춘 프레임워크를 원한다면 Django 사용을 고려했을 것입니다. 불행하게도 Django는 성능으로 유명하지 않습니다. 아마도 기본 동기식 서빙(동기화) 때문일 것입니다.
동시에 여러 복숭아를 처리하는 비단뱀! (출처: https://labs.openai.com/)

나처럼 빠른 웹 개발을 위한 완전한 기능을 갖춘 프레임워크를 원한다면 Django 사용을 고려했을 것입니다.

불행하게도 Django는 성능으로 유명하지 않습니다. 아마도 기본 동기식 서빙(동기화) 때문일 것입니다. 이것이 소개가 진행되는 것입니다(https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

서버 스레드가 한 번에 하나의 요청만 처리할 수 있기 때문에 동기화가 제대로 수행되지 않습니다. 중요한 것은 I/O 요청이 스레드를 차단한다는 것입니다. 스레드를 추가하여 처리량을 늘릴 수 있지만 Python 서버 스레드는 Python 서버 프로세스와 거의 동일한 양의 RAM을 사용합니다. 이로 인해 스레드를 추가하여 처리량을 늘릴 수 없습니다.

Django 3.0에서 비동기식 디자인(Async)이 추가되었습니다(https://docs.djangoproject.com/en/3.0/topics/async/), 도입부에 언급되지 않았습니다. Django(https://docs.djangoproject.com/en/4.1/topics/).

비동기는 단일 비동기 서버 스레드가 한 번에 여러 요청을 처리할 수 있으므로 동기화를 능가할 수 있습니다. 대기 가능한 I/O 요청 중에 await는 제어를 다시 스레드에 양보하고 다른 요청이 진행되도록 허용합니다. Javascript의 이벤트 루프와 비슷하지만 완전히(https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

그래서 다음과 같은 질문이 생겼습니다.

  • Django는 비동기 설정을 사용하여 얼마나 많은 성능 향상을 얻을 수 있습니까?
  • 경로/엔드포인트가 무엇을 하고 있는지(CPU가 많거나 IO가 많음)가 중요합니까?
  • 대기 시간이 짧거나(서버가 근처에 있음) 대기 시간이 길면(서버가 멀리 있음) 어떻게 됩니까?

그래서 부하 테스트 실험을 진행했습니다. 여기에서 코드와 세부 정보를 찾을 수 있습니다.https://github.com/nalkhish/Asynchronize-Django. 간략히 조사한 내용은 다음과 같습니다.

For each setup (sync, async limited, async unlimited):
    For each request route (high cpu, high io create, high io read):
        For each added latency (200, 100, 0):
            Emulate load and generate report

  • Sync의 경우 Gunicorn을 사용하여 Sync 작업자 1명을 가동했습니다(https://docs.gunicorn.org/en/stable/design.html).
  • Async Limited의 경우 Gunicorn은 Uvicorn 작업자 1명을 스핀업하는 데 사용되었습니다(https://www.uvicorn.org/deployment/)
  • Async Unlimited의 경우 Gunicorn을 사용하여 1명의 Uvicorn 작업자(https://www.uvicorn.org/deployment/), 그러나 postgres는 1000개의 동시 연결을 허용하도록 설정되었습니다(기본값 100 이상).

3가지 경로가 사용되었습니다.

  • cpu — 경로가 CPU를 많이 사용함을 나타냅니다.
  • def cpu(request: HttpRequest):
        """High cpu route
        CPU: High
        I/O: low
        """
        ct = 0
        for i in range(10**7):
            ct += i * i
        return JsonResponse({"ct": ct})
    

    # In sync
    def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = Post.objects.create(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    
    # In async
    async def io_create(request: HttpRequest):
        """Create 1 post
        CPU: low
        I/O: High
        """
        new_post = await Post.objects.acreate(
            title=request.POST["title"],
            content=request.POST["content"],
        )
        return JsonResponse(new_post.to_dict())
    

    # In sync
    def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = Post.objects.all()[:25]
        ct = Post.objects.count()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    
    # In Async
    async def io_read(request: HttpRequest):
        """Get first 25 posts
        CPU: low
        I/O: High
        """
        posts = []
        async for p in Post.objects.all()[:25]:
            posts.append(p)
        ct = await Post.objects.acount()
        return JsonResponse(
            {
                "ct": ct,
                "posts": [post.to_dict() for post in posts],
            }
        )
    

docker exec <container_name> tc qdisc add dev eth0 root netem delay 100ms

여기서 <container_name>은 컨테이너의 이름입니다.

부하 에뮬레이션

로드는 오픈 소스 도구인 Locust(https://locust.io/). Locust는 요청으로 포트를 공격합니다. '사용자'가 작업을 완료하는 즉시 다시 시작됩니다.

총 로드는 3분 동안 지속되었습니다.

  • 0명의 사용자로 시작했습니다.
  • 처음 100초 동안 초당 5명의 사용자 비율로 증가하여 최대 동시 사용자 수는 500명입니다.
  • 마지막 80초 동안 사용자는 500명을 유지했습니다.

결과:

정의:

  • 최대 sRPS: 전체 보고서 그래프에 대한 전체 요청과 실패한 요청 간의 최대 차이를 취하여 초당 최대 성공한 요청을 얻었습니다( repo 의 보충 그림 참조 ).
  • 총 성공 횟수: #total requests에서 #failed requests를 빼서 전체 성공한 요청 수를 얻었습니다( repo 의 보충 수치 참조 ).

표 1: CPU 바운드. 설정(동기화/비동기 제한/비동기 무제한) 및 대기 시간(0ms, 100ms, 200ms)의 조합에 대한 최대 sRPS 및 전체 성공. 비동기는 실제로 제대로 작동하지 않습니다.

비동기 제한 vs 비동기 무제한:

  • Async Unlimited가 Async Limited보다 나은 이유는 무엇입니까? 비동기 무제한은 더 많은 데이터베이스 연결을 허용하지만 CPU 경로 및 미들웨어 설정은 데이터베이스를 사용하지 않습니다. 이것은 더 조사할 필요가 있습니다.
  • 어쨌든 두 비동기 설정 모두 예측할 수 없는 역학을 가지고 있습니다(repo의 보충 그림 참조).https://github.com/nalkhish/Asynchronize-Django).
  • 동기는 비동기 무제한보다 최대 sRPS가 낮았습니다. 비동기 서버가 한 번에 여러 요청을 처리할 수 있어서 여러 요청이 동시에 완료되었기 때문일 수 있습니다. 이것은 asyncio가 cpu 경로에 존재하지 않는 await 문에 도달하지 않는 한 컨텍스트를 전환하지 않기 때문에 놀랍습니다. 이것은 더 조사할 필요가 있습니다.
  • Sync는 예측 가능한 역학을 가지고 있으며 Async보다 전반적인 성공률이 더 높았습니다. 이것은 cpu 바인딩 서비스에 동기화를 사용하는 것을 보증하기에 충분합니다.

표 2: I/O 바운드 DB 읽기. 서버(비동기/동기) 및 대기 시간(0ms, 100ms, 200ms)의 조합에 대한 최대 sRPS 및 전체 성공. 동기화 대신 비동기를 사용할 때 긴 대기 시간에서 더 높은 처리량이 있습니다.

표 3: I/O 바운드 DB 생성. 서버(비동기/동기) 및 대기 시간(0ms, 100ms, 200ms)의 조합에 대한 최대 sRPS 및 전체 성공. 비동기를 사용할 때 대기 시간이 길면 처리량이 더 높아집니다.

비동기 제한 대 비동기 무제한: 비동기 무제한은 비동기 제한보다 최대 sRPS와 전반적인 성공률이 더 높았습니다.

  • IO 바운드 읽기 경로의 경우 데이터베이스가 실패하여 병목 현상이 발생했기 때문일 수 있습니다.
  • IO 바인딩 생성 경로의 경우 데이터베이스가 비동기 제한에 대해 실패하지 않았기 때문에 추가 조사가 필요합니다(보충 그림 참조).
  • io_read 및 io_create 경로 모두에서 Sync는 Async보다 훨씬 낮은 성능을 보였습니다(200ms의 추가 대기 시간에 대해 전체 처리량의 차이는 io_read의 경우 40배 , io_create의 경우 230배 였습니다).
  • 서버 작업자 스레드가 다음 요청을 처리하기 전에 데이터베이스 요청이 완료되기를 기다리고 있었기 때문일 수 있습니다. 이 이론은 대기 시간과 최대 sRPS 사이의 반비례 관계와 Sync의 전반적인 성공에 의해 뒷받침됩니다.

제한 사항은 repo readme에서 자세히 살펴봅니다.https://github.com/nalkhish/Asynchronize-Django(여기서 논의된 작은 정보, 재시도 로직의 부재 및 인바운드 대기 시간의 부재), 이 연구는 비동기 및 동기 Django 서빙 기능을 충분히 탐구합니다.

Django를 사용하고 CPU 바인딩 부하가 있는 경우 Sync를 사용하십시오. 그렇지 않고 로드가 I/O 바인딩된 경우 Async를 사용합니다. 작업자 처리량이 10배 이상 증가할 가능성이 높습니다.