+12

Concurrency and Parallelism trong python. Sức mạnh của Asynchronous

I.Giới thiệu

Trong thời đại các mô hình ngôn ngữ lớn (LLM) và các API cho AI service phát triển mạnh mẽ, việc xây dựng các ứng dụng sử dụng chúng ngày càng phổ biến. Tuy nhiên, khi ứng dụng phải phục vụ số lượng lớn người dùng, việc xử lý đồng thời nhiều yêu cầu (request) trở thành một thách thức lớn. Các API thường có giới hạn tài nguyên và thời gian phản hồi, đòi hỏi lập trình viên không chỉ cân nhắc về cân bằng tải mà còn phải tổ chức mã nguồn sao cho hiệu quả.

Trong quá trình làm việc với các dự án liên quan đến quản lý số lượng lớn request, tôi nhận thấy có hai cách tiếp cận phổ biến để xử lý vấn đề này: sử dụng đa luồng (multithreading) hoặc lập trình bất đồng bộ (asynchronous programming). Mỗi phương pháp đều có ưu và nhược điểm, và việc lựa chọn không chỉ phụ thuộc vào yêu cầu của dự án mà còn vào cách bạn tổ chức logic xử lý.

Mục tiêu của bài viết này là làm rõ khái niệm Concurrency và Parallelism trong Python, giúp bạn hiểu sâu hơn về hai phương pháp này để áp dụng một cách hợp lý vào dự án của mình.

Pre requisite: Để tránh bài viết bị quá dài mình sẽ không nói lại các kiến thức cơ bản, bạn cần có hiểu biết nền tảng về multithreading (đa luồng)asynchronous (bất đồng bộ) trong python. Phần bất đồng bộ sẽ được mình giải thích chi tiết hơn trong phần sau.

II. Khái niệm

  • Concurrency hay xử lý đồng thời, theo wiki, là khả năng của một hệ thống thực hiện nhiều tác vụ thông qua việc thực hiện cùng lúc hoặc là chia sẻ thời gian, chia sẻ tài nguyên và quản lý tương tác. Khi thực hiện xử lý 2 tác vụ một cách đồng thời, 2 task này không bắt đầu và kết thúc một cách nối tiếp, mà một task có thể được bắt đầu trước khi task còn lại kết thúc.

Hình 1

  • Parallelism, xử lý song song, hay tính toán song song là khả năng thực hiện nhiều tác vụ cùng lúc mà việc thực hiện task vụ này không làm gián đoạn tác vụ khác

Hình 2

  • Vậy sự khác nhau giữa 2 khái niệm này là gì? Trong concurrency, các task chỉ cần bắt đầu và kết thúc gối nhau, chứ không cần các công việc của task được thực thi cùng lúc trong cùng một thời điểm. Còn trong parallelism thì có. Hay parallelism chính là tập con của concurrency. Conncurency có thể được thực hiện trên một lõi CPU của vi xử lý, khi đó, CPU sẽ phân chia thời gian lúc thì thực hiện task này, lúc lại thực hiện task kia theo một cơ chế nhất định. Còn đối với Parallelism, cần phải có một vi xử lý với nhiều lõi CPU, khi đó mỗi task sẽ được thực thi trên một CPU độc lập.

Hình 3

Từ giờ mình sẽ việt hóa 2 từ này, gọi Concurrency là đồng thời và ám chỉ thực hiện task vụ không song song còn Parallelism là song song. Trong Python, đồng thời và song song được triển khai bằng các thư viện khác nhau:

  • Đồng thời thường được triển khai bằng multithreading (do GIL - Global Interperter Lock, một cơ chế để hạn chế chỉ một luồng được thực thi ở một thời điểm, khiến cho multithreading chỉ thực hiện đồng thời các threads) và lập trình bất đồng bộ (asynchronous). Nó cho phép chương trình xử lý nhiều tác vụ bằng cách chuyển đổi giữa chúng, tiến hành trên mỗi tác vụ mà không nhất thiết phải chạy chúng cùng một lúc.
  • Song song thường đạt được bằng cách sử dụng multiprocessing, chạy nhiều tiến trình trên các lõi CPU khác nhau.

Trong bài viết này mình sẽ tập chung vào sự khác nhau giữa multithreading và asynchronous nên sẽ không đi sâu phần phần multiprocessing

Multithreading (đa luồng)

Multithreading hay đa luồng là một kỹ thuật lập trình cho phép một chương trình thực hiện nhiều tác vụ đồng thời trong cùng một tiến trình. Trong Python, đa luồng được sử dụng để cải thiện hiệu suất của các ứng dụng I/O-bound, nơi mà các tác vụ bị chậm do chờ đợi I/O (nhập/xuất) như đọc/ghi tệp, truy vấn cơ sở dữ liệu, hoặc kết nối mạng.

Global Interpreter Lock (GIL)

GIL là một khóa toàn cục trong Python, đảm bảo rằng chỉ một luồng thực thi mã bytecode Python tại một thời điểm. Điều này có nghĩa:

  • Đa luồng không hiệu quả cho tác vụ CPU-bound: Vì GIL ngăn cản các luồng thực thi đồng thời trên nhiều lõi CPU.
  • Đa luồng hữu ích cho tác vụ I/O-bound: Trong khi một luồng chờ I/O, GIL có thể được nhường cho luồng khác.

Do GIL, các threads trong python sẽ được luân phiên thực hiện bởi CPU theo một chiến lược nhất định, ví dụ như thực hiện một thread trong một khoảng thời gian Δτ\Delta\tau rồi chuyển sang thread khác,, luân phiên qua lại

Luồng (Thread) và Tiến Trình (Process)
  • Tiến trình (Process): Là một phiên bản đang chạy của một chương trình, có không gian bộ nhớ và tài nguyên riêng.
  • Luồng (Thread): Là một đơn vị thực thi nhỏ hơn trong tiến trình. Các luồng trong cùng một tiến trình chia sẻ cùng không gian bộ nhớ và tài nguyên.

So sánh:

  • Đa tiến trình (Multiprocessing): Mỗi tiến trình có GIL riêng, không chia sẻ bộ nhớ, an toàn hơn nhưng tốn tài nguyên hơn.
  • Đa luồng (Multithreading): Chia sẻ bộ nhớ, nhẹ hơn nhưng cần quản lý đồng bộ hóa để tránh xung đột dữ liệu.

Asynchronous

Bất đồng bộ (asynchronous programming) là một kỹ thuật lập trình cho phép xử lý nhiều tác vụ cùng một lúc mà không cần đợi mỗi tác vụ hoàn thành trước khi chuyển sang tác vụ tiếp theo. Trong Python, bất đồng bộ đã trở thành một phần quan trọng của ngôn ngữ, đặc biệt với sự ra đời của module asyncio trong Python 3.4 và các từ khóa asyncawait trong Python 3.5.

1. Tại Sao Cần Bất Đồng Bộ?

  • Hiệu suất cao hơn: Bất đồng bộ cho phép chương trình xử lý nhiều tác vụ I/O cùng một lúc, cải thiện hiệu suất tổng thể.
  • Phản hồi nhanh hơn: Trong ứng dụng web, bất đồng bộ giúp xử lý nhiều yêu cầu khách hàng đồng thời mà không gây chậm trễ.
  • Sử dụng tài nguyên hiệu quả: Tiết kiệm tài nguyên hệ thống bằng cách tránh việc tạo nhiều thread hoặc process.

2. Sự Khác Biệt Giữa Đồng Bộ Và Bất Đồng Bộ

  • Đồng bộ: Mỗi tác vụ được thực hiện tuần tự. Chương trình sẽ chờ một tác vụ hoàn thành trước khi chuyển sang tác vụ tiếp theo.
  • Bất đồng bộ: Cho phép chuyển sang tác vụ khác trong khi chờ tác vụ trước hoàn thành, thường áp dụng cho các tác vụ I/O.

3. asyncio Module

asyncio là một thư viện trong Python cung cấp hỗ trợ cho lập trình bất đồng bộ (asynchronous programming) sử dụng coroutine. Được giới thiệu từ Python 3.4, asyncio giúp bạn viết mã không đồng bộ một cách dễ dàng và hiệu quả.

Dưới đây là một số thành phần của asyncio:

3.1. Event Loop

Event Loop là trung tâm của asyncio. Nó là một vòng lặp vô hạn chịu trách nhiệm quản lý và điều phối việc thực thi các coroutine, quản lý I/O, và xử lý các sự kiện theo lịch trình.

Cách hoạt động của Event Loop:

  • Bắt đầu và quản lý: Khi bạn khởi chạy một chương trình asyncio, event loop sẽ được tạo ra (hoặc lấy event loop hiện tại) và bắt đầu chạy.
  • Điều phối CoroutinesTasks: Event loop quản lý danh sách các coroutinetasks cần được thực thi.
  • Xử lý I/O bất đồng bộ: Nó chờ đợi các sự kiện I/O hoặc thời gian (timer) xảy ra và kích hoạt các callback tương ứng.
  • Chuyển đổi ngữ cảnh: Khi một coroutine tạm dừng tại một điểm await, event loop sẽ chuyển sang thực thi coroutine khác đang chờ đợi, đảm bảo sử dụng hiệu quả thời gian CPU.

3.2. Coroutines

Coroutine là một hàm có thể tạm dừng thực thi của nó và trả quyền điều khiển lại cho event loop, để event loop có thể thực thi các coroutine khác. Trong Python, coroutine được định nghĩa bằng cách sử dụng từ khóa async def. Dùng await để tạm dừng coroutine hiện tại cho đến khi biểu thức sau await hoàn thành.

Coroutines không tự động chạy khi được gọi. Chúng cần được chạy bởi event loop thông qua await, asyncio.run(), asyncio.gather(), hoặc được chuyển đổi thành task.

3.3. Tasks task là một đối tượng bao bọc một coroutine và lên lịch để nó chạy trong event loop. Nó cho phép thực hiện đồng thời nhiều coroutine trong cùng một event loop bằng cách chuyển đổi ngữ cảnh tại các điểm await.

Cách tạo và chạy Tasks:

task = asyncio.create_task(my_coroutine())

Khi tạo một Task, nó được thêm vào event loop và sẽ bắt đầu thực thi khi event loop chạy. Ngoài ra có thể sử dụng các hàm như asyncio.gather() để chạy nhiều task đồng thời và chờ đợi kết quả của chúng.

3.4. Futures

Future là một đối tượng đại diện cho một kết quả sẽ có trong tương lai. Trong asyncio, Future thường được sử dụng để biểu diễn kết quả của các thao tác bất đồng bộ chưa hoàn thành.

Cách sử dụng Future:

  • Tạo Future: Thông thường, Futures được tạo và quản lý bởi event loop và các API cấp thấp.
  • Chờ đợi Future: Bạn có thể await trên một Future để chờ đợi kết quả của nó.
  • Hoàn thành Future: Một Future có thể được hoàn thành bằng cách gọi set_result() hoặc set_exception().
future = loop.create_future()

# Hoàn thành Future ở một nơi nào đó trong code
future.set_result('Result')

# Chờ đợi kết quả của Future
result = await future

4. Cơ chế hoạt động của asyncio

Trước hết chúng ta cùng xem xét ví dụ sau:

import asyncio
import time

async def func_1():
    print('func_1 started.')
    s = time.time()
    await asyncio.sleep(1)
    e = time.time()
    print('func_1 ended.')
    print(f'func_1 time executed: {e - s}')

async def func_2():
    print('func_2 started.')
    s = time.time()
    time.sleep(2)
    e = time.time()![](https://images.viblo.asia/46219b79-d814-44a5-b604-258a00990dd4.png)

    print('func_2 ended.')
    print(f'func_2 time executed: {e - s}')

async def func_3():
    print('func_3 started.')
    s = time.time()
    await asyncio.sleep(2)
    e = time.time()
    print('func_3 ended.')
    print(f'func_3 time executed: {e - s}')

async def test1():
    task_1 = asyncio.create_task(func_1())
    task_2 = asyncio.create_task(func_2())
    await asyncio.gather(task_1, task_2)

async def test2():
    task_1 = asyncio.create_task(func_1())
    task_2 = asyncio.create_task(func_3())
    await asyncio.gather(task_1, task_2)

if __name__ == "__main__":
    print("-----test 1-----")
    asyncio.run(test1())
    print("-----test 2-----")
    asyncio.run(test2())

Và đây là output:

-----test 1-----
func_1 started.
func_2 started.
func_2 ended.
func_2 time executed: 2.0001540184020996
func_1 ended.
func_1 time executed: 2.000375270843506
-----test 2-----
func_1 started.
func_3 started.
func_1 ended.
func_1 time executed: 1.0008909702301025
func_3 ended.
func_3 time executed: 2.002143144607544

Có gì đó sai sai phải không ạ, tại sao trong test 1, func_1 lại mất tận 2s trong khi sleep có 1s 🤔 . Hãy cùng xem luồng hoạt động sau đây:

  1. Đầu tiên, khi khởi tạo task với create_task, coroutine func_1 được đưa vào task queue và thực thi bởi event loop.
  2. Khi gặp await asyncio.sleep, một os timer được khởi chạy
  3. coroutine bị suppend
  4. task_2 được tạo với coroutine func_2
  5. Hàm sleep được gọi. Tuy nhiên do sử dụng hàm đồng bộ và không có await nên coroutine này sẽ đợi đủ 2s mà không suppend
  6. Trong khi đó, timer của task_1 đã chạy đủ 1s nên sẽ gọi đến callback của hàm sleep. task_1 lúc này được coi là đã hoàn thành, tuy nhiên event loop lại đang bận thực hiện task_2
  7. Sau 2s, task 2 được tiếp tục được thực thi
  8. Khi task_2 hoàn thành, event loop lúc này mới kiểm tra task_1 và thực hiện tiếp các công việc còn lại

\Rightarrow Khi sử dụng bất đồng bộ trong lập trình, các coroutines cũng phải được thiết kế bất đồng bộ nếu không sẽ ảnh hưởng block và ảnh hưởng đến các coroutines khác

Khi được sử dụng đúng như trong test_2, luồng hoạt động như sau:

  1. Đầu tiên, khi khởi tạo task với create_task, coroutine func_1 được đưa vào task queue và thực thi bởi event loop.
  2. Khi gặp await asyncio.sleep, một os timer được khởi chạy
  3. coroutine bị suppend
  4. task_2 được tạo với coroutine func_2
  5. await asyncio.sleep được gọi. coroutine này bị suppended
  6. Quyền thực thi được trả lại cho event_loop
  7. Do không còn task nào sẵn sàng để thực thi (cả 2 task đang trong trạng thái suppend) nên event_loop sẽ lặp qua lại các task để kiểm tra trạng thái
  8. Hết 1s, callback của task_1 được gọi, trạng thái được thay đổi
  9. task 1tiếp tục được thực thi
  10. callback của task_2 được gọi, trạng thái được thay đổi
  11. task 2 tiếp tục được thực thi

\Rightarrow Không giống như trường hợp trên, khi callback ở func_1 được gọi, event_loop đang không có task nào được thực thi nên sẽ được tiếp tục ngay lập tức

5. Ưu điểm của bất đồng bộ

  • Trong một ứng dụng không cần tính toán nặng, các task vụ liên quan đến I/O sẽ chiếm phần lớn thời gian, tuy nhiên các task như thế lại có thể được handle với hệ điều hành, ứng dụng sẽ có thời gian đi thực hiện các nhiệm vụ khác như là xử lý các request tiếp theo \Rightarrow có thể xử lý nhiều request hơn
  • So với đa luồng và đa tiến trình, xử lý bất đồng bộ tốn ít tài nguyên hơn do chỉ phải lưu trạng thái các task - thứ chiếm ít tài nguyên hơn context của một luồng/tiến trình rất nhiều

III. Ví dụ

Trong phần này mình sẽ làm nổi bật sức mạnh của xử lý bất đồng bộ so với xử lý đa luồng trong python bằng cách xây dựng 2 fastAPI app. Một app sử dụng bất đồng bộ, một app sử dụng thread với các hàm đồng bộ.

app bất đồng bộ:

import asyncio
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def read_async():
    await asyncio.sleep(1)
    return {"message": "Asynchronous Response", "status": 200}

app đồng bộ:

from fastapi import FastAPI
import time

app = FastAPI()

@app.get("/")
def read_sync():
    time.sleep(1)
    return {"message": "Synchronous Response", "status": 200}

Các app này đều được chạy với uvicorn: uvicorn run main:app --workers 1

Sau đó sử dụng Apache benchmarking tool để test thử hiệu năng của 2 ứng dụng và ghi lại số lượng request mỗi giây mà ứng dụng có thể xử lý: ab -n <total_requests> -c <concurrent_requests> http://localhost:<port>/

Trong đó:

  • total_requests: tổng số lượng request gửi đi
  • concurrent_requests: số lượng requests gửi đổng thời

Và đây là kết quả:

total_requests concurrent_requests multithread (req/s) async (req/s)
1000 10 9.78 9.78
1000 100 38.0 88.94
1000 200 37.98 162.17
1000 400 37.92 235.23
1000 800 37.90 303.54
5000 800 591.33
5000 1500 888.51
5000 2500 1248.84
10000 5000 1894.58
100000 10000 2665.12

Các lần test sau do kết quả của multithread không tăng quá nhiều nên mình không thực hiện nữa. Nhìn vào bảng kết quả có thể thấy rõ sử dụng async cho performance tốt thế nào.

Giải thích cho sự chênh lệch này chính là do cơ chế luân phiên thay đổi các luồng hoạt động khi chạy đa luồng. Khi xét một luồng đã xử lý xong, nhưng lại phải đợi luân phiên hết các luồng khác mới đến lượt thực hiện tiếp, do đó nếu càng nhiều luồng hoạt động thì thời gian chờ sẽ càng lâu. Một lý do nữa đó là với async function, khi gặp await sleep thì coroutine bị suppend ngay và event_loop lặp qua các coroutine để kiểm tra trạng thái chứ không nán lại quá lâu, còn với đa luồng, khi một luồng đang sleep, nó vẫn được thực hiện trong một khoảng thời gian ngắn mới chuyển sang luồng tiếp theo. Điều này khiến lãng phí tài nguyên không cần thiết.

IV Tổng kết

Qua bài viết này mình đã sự vượt trội của xử lý bất đồng bộ so với đa luồng khi triển kha ứng dụng có lượng lớn người dùng. Giải thích lý do cho ưu điểm đó. Tuy nhiên tại sao ứng dụng đa luồng vẫn còn tồn tại? Thì dưới đây là lý do chính:

  • Bất đồng bộ mang liệu hiệu quả cao cho ứng dụng có nhiều người dùng đồng thời nhưng thiết kế code phức tạp hơn và yêu cầu người phát triển phải hiểu rõ về nó.
  • Đa luồng tuy không cho hiệu năng tốt bằng nhưng có cách thiết kế code đơn giản hơn, do đó nên ưu tiên sử dụng khi ứng dụng không cần xử lý lượng lớn yêu cầu một lúc. Và đa luồng thường được kết hợp với các cấu trúc dữ liệu như Queue, Semaphore, Lock, Event,... Ví dụ cho việc này như một hệ thống ghi log ra file, sử dụng một queue để lưu trữ log tạm thời và ghi vào file khi nào rảnh. Việc ghi log này không yêu cầu độ trễ thấp cũng như xử lý nhiều log trong một thời điểm nên xử dụng thread sẽ đơn giản hơn.

Hy vọng bài viết này có ích cho các bạn và giúp cho các bạn có thêm một cách khi xây dựng ứng dụng. Nếu có gì sai sót trong bài viết vui lòng comments phía dưới để bài viết được hoàn thiện hơn.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí