คุณควรใช้ Django Asynchronous Support หรือไม่

Nov 28 2022
เช่นเดียวกับฉัน คุณอาจพิจารณาใช้ Django หากคุณต้องการเฟรมเวิร์กที่มีคุณสมบัติครบถ้วนสำหรับการพัฒนาเว็บอย่างรวดเร็ว น่าเสียดายที่ Django ไม่ได้รับการยกย่องในด้านประสิทธิภาพ — อาจเป็นเพราะบริการซิงโครนัส (Sync) ที่เป็นค่าเริ่มต้น
งูเหลือมจัดการลูกพีชหลายลูกพร้อมกัน! (ที่มา: https://labs.openai.com/)

เช่นเดียวกับฉัน คุณอาจพิจารณาใช้ Django หากคุณต้องการเฟรมเวิร์กที่มีคุณสมบัติครบถ้วนสำหรับการพัฒนาเว็บอย่างรวดเร็ว

น่าเสียดายที่ Django ไม่ได้รับการยกย่องในด้านประสิทธิภาพ — อาจเป็นเพราะบริการซิงโครนัส (Sync) ที่เป็นค่าเริ่มต้น นี่คือสิ่งที่แนะนำผ่าน (https://docs.djangoproject.com/en/4.1/intro/tutorial01/).

การซิงค์ทำงานได้ไม่ดีเนื่องจากเธรดของเซิร์ฟเวอร์สามารถให้บริการได้ครั้งละหนึ่งคำขอเท่านั้น ที่สำคัญ คำขอ I/O บล็อกเธรด ปริมาณงานสามารถเพิ่มได้โดยการเพิ่มเธรด แต่เธรดเซิร์ฟเวอร์ Python ใช้ RAM เกือบเท่ากันกับกระบวนการเซิร์ฟเวอร์ Python สิ่งนี้ทำให้ไม่สามารถเพิ่มปริมาณงานโดยการเพิ่มเธรด

มีการเพิ่มการออกแบบแบบอะซิงโครนัส (Async) ใน Django 3.0 (https://docs.djangoproject.com/en/3.0/topics/async/) และไม่ได้กล่าวถึงในบทนำ กล่าวถึงเป็นหัวข้อสุดท้ายในการใช้ Django (https://docs.djangoproject.com/en/4.1/topics/).

Async มีประสิทธิภาพดีกว่าการซิงค์เนื่องจากเธรดเซิร์ฟเวอร์ Async เดียวสามารถให้บริการหลายคำขอในแต่ละครั้ง ระหว่างการร้องขอ I/O ที่รอคอย การรอคอยจะให้การควบคุมกลับไปยังเธรดและอนุญาตให้คำขออื่นๆ ดำเนินการต่อ มันคล้ายกับการวนรอบเหตุการณ์ของ Javascript แต่ไม่ทั้งหมด (https://stackoverflow.com/questions/68139555/difference-between-async-await-in-python-vs-javascript).

เลยทำให้เกิดคำถามว่า

  • Django สามารถเพิ่มประสิทธิภาพได้มากเพียงใดโดยใช้การตั้งค่าแบบอะซิงโครนัส
  • เส้นทาง/จุดสิ้นสุดกำลังทำอะไรอยู่ (ใช้ CPU มาก vs ใช้ 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 ใช้เพื่อหมุน 1 Sync worker (https://docs.gunicorn.org/en/stable/design.html).
  • สำหรับ Async Limited นั้น Gunicorn ถูกใช้เพื่อปั่นคนงาน Uvicorn 1 คน (https://www.uvicorn.org/deployment/)
  • สำหรับ Async Unlimited นั้น Gunicorn ใช้เพื่อหมุนคนงาน Uvicorn 1 คน (https://www.uvicorn.org/deployment/) แต่ postgres ได้รับการตั้งค่าให้อนุญาตการเชื่อมต่อพร้อมกัน 1,000 รายการ (มากกว่าค่าเริ่มต้น 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/). ตั๊กแตนโจมตีพอร์ตด้วยคำขอ ทันทีที่ 'ผู้ใช้' ทำงานเสร็จ มันก็จะเริ่มต้นใหม่อีกครั้ง

การโหลดทั้งหมดใช้เวลา 3 นาที:

  • เริ่มต้นด้วย 0 ผู้ใช้
  • ในช่วง 100 วินาทีแรก เพิ่มอัตราผู้ใช้ 5 คนต่อวินาทีเพื่อจำกัดผู้ใช้พร้อมกัน 500 คน
  • ในช่วง 80 วินาทีที่ผ่านมา ผู้ใช้ยังคงอยู่ที่ 500

ผลการวิจัย:

คำจำกัดความ:

  • sRPS สูงสุด: คำขอที่สำเร็จสูงสุดต่อวินาทีได้รับจากความแตกต่างสูงสุดระหว่างคำขอโดยรวมและคำขอที่ล้มเหลวสำหรับกราฟรายงานทั้งหมด (ดูตัวเลขเสริมในrepo )
  • ความสำเร็จทั้งหมด: ได้รับคำขอที่สำเร็จโดยรวมโดยการลบ #คำขอที่ล้มเหลว ออกจาก #คำขอทั้งหมด (ดูตัวเลขเพิ่มเติมในrepo )

ตารางที่ 1: CPU-bound sRPS สูงสุดและความสำเร็จโดยรวมสำหรับการตั้งค่าร่วมกัน (Sync/Async Limited/Async unlimited) และ Latency (0ms, 100ms, 200ms) Async ทำงานได้ไม่ดีจริง ๆ

Async จำกัด กับ Async ไม่จำกัด:

  • ทำไม Async unlimited ถึงดีกว่า Async limited? Async unlimited ช่วยให้สามารถเชื่อมต่อฐานข้อมูลได้มากขึ้น แต่เส้นทาง CPU และการตั้งค่ามิดเดิลแวร์ไม่ได้ใช้ฐานข้อมูล สิ่งนี้จะต้องมีการตรวจสอบเพิ่มเติม
  • ไม่ว่าในกรณีใด การตั้งค่า Async ทั้งสองมีไดนามิกที่คาดเดาไม่ได้ (ดูตัวเลขเพิ่มเติมใน repohttps://github.com/nalkhish/Asynchronize-Django).
  • การซิงค์มี sRPS สูงสุดต่ำกว่า Async unlimited อาจเป็นเพราะเซิร์ฟเวอร์ async สามารถจัดการคำขอหลายรายการพร้อมกันได้ ดังนั้นคำขอหลายรายการจึงเกิดขึ้นพร้อมกัน สิ่งนี้น่าประหลาดใจเพราะ asyncio คาดคะเนว่าจะไม่สลับบริบท เว้นแต่จะโดนคำสั่ง wait ซึ่งไม่มีอยู่ในเส้นทาง cpu สิ่งนี้จะต้องมีการตรวจสอบเพิ่มเติม
  • Sync มีไดนามิกที่คาดเดาได้และมีความสำเร็จโดยรวมสูงกว่า Async นี่เพียงพอที่จะรับประกันการใช้ Sync สำหรับบริการที่ผูกกับ cpu

ตารางที่ 2: การอ่านฐานข้อมูล I/O-bound sRPS สูงสุดและความสำเร็จโดยรวมสำหรับการรวมกันของเซิร์ฟเวอร์ (Async/Sync) และเวลาแฝง (0ms, 100ms, 200ms) มีทรูพุตที่สูงขึ้นในเวลาแฝงสูงเมื่อใช้ Async แทน Sync

ตารางที่ 3: การสร้างฐานข้อมูล I/O-bound sRPS สูงสุดและความสำเร็จโดยรวมสำหรับการรวมกันของเซิร์ฟเวอร์ (Async/Sync) และเวลาแฝง (0ms, 100ms, 200ms) มีทรูพุตที่สูงขึ้นในเวลาแฝงสูงเมื่อใช้ Async

Async limited vs Async unlimited: Async unlimited มี sRPS สูงสุดและความสำเร็จโดยรวมสูงกว่า Async limited

  • สำหรับเส้นทางการอ่านที่เชื่อมโยงกับ IO อาจเกิดจากฐานข้อมูลที่เป็นคอขวดเนื่องจากล้มเหลว
  • สำหรับเส้นทางการสร้างขอบเขต IO จะต้องตรวจสอบเพิ่มเติมเนื่องจากฐานข้อมูลไม่ได้ล้มเหลวสำหรับ Async limited (ดูรูปเพิ่มเติม)
  • สำหรับทั้งเส้นทาง io_read และ io_create การซิงค์มีประสิทธิภาพต่ำกว่า Async มาก (สำหรับเวลาแฝงที่เพิ่มขึ้น 200 มิลลิวินาที ความแตกต่างของปริมาณงานโดยรวมคือ40 เท่าสำหรับ io_read และ230 เท่าสำหรับ io_create)
  • อาจเป็นเพราะเธรดผู้ปฏิบัติงานเซิร์ฟเวอร์กำลังรอให้คำขอฐานข้อมูลเสร็จสิ้นก่อนที่จะสามารถจัดการคำขอถัดไปได้ ทฤษฎีนี้สนับสนุนโดยความสัมพันธ์แบบผกผันระหว่างเวลาแฝงกับ sRPS สูงสุด และความสำเร็จโดยรวมสำหรับการซิงค์

มีการสำรวจข้อจำกัดเพิ่มเติมที่ repo readmehttps://github.com/nalkhish/Asynchronize-Django(ข้อมูลเล็กน้อยที่กล่าวถึงในที่นี้ การไม่มีตรรกะในการลองใหม่ และไม่มีความล่าช้าขาเข้า) แต่การศึกษานี้สำรวจความสามารถของการให้บริการ Django แบบอะซิงโครนัสและซิงโครนัสอย่างเพียงพอ

หากคุณใช้ Django และมี CPU-bound load ให้ใช้ Sync มิฉะนั้น หากโหลดเป็น I/O-bound ให้ใช้ Async มีแนวโน้มว่าจะมีมากกว่า 10 เท่าของปริมาณงานของผู้ปฏิบัติงานของคุณ