Xử lý đồng thời trong python

October 23, 2019
Concurrency Python

Python thì không phải là phương án tối ưu nhất cho việc xử lý các process đồng thời. Tuy nhiên ở bài viết này mình sẽ viết thử 1 scripts nho nhỏ để kiểm chứng hiệu suất của việc xử lý concurrentcy này.

Scripts tải image đơn giản

import urllib.request
import time


url = 'https://picsum.photos/id/{}/200/300'
args = [(n, url.format(n)) for n in range(20)]
start = time.time()

for img_id, url in args:
    res = urllib.request.urlopen(url)
    pic = res.read()
    with open(f'./{img_id}.jpg', 'wb') as f:
        f.write(pic)
    print(f'Picture {img_id} saved!')

msg ='Spend time {:.3f}'
print(msg.format(time.time() - start))

scripts trên đang thực hiện download tuần tự 20 bức ảnh và output xuất ra màn hình

Picture 0 saved!
Picture 1 saved!
...

Picture 19 saved!

Spend time 4.491

Bây giờ mình sẽ viết 1 function cho công việc download image và sử dụng map để call function đó

import urllib.request
import time

def download_img(img_id, url):
    res = urllib.request.urlopen(url)
    pic = res.read()
    with open(f'./{img_id}.jpg', 'wb') as f:
        f.write(pic)
    print(f'Picture {img_id} saved!')

def main():
    url = 'https://picsum.photos/id/{}/200/300'
    args = [(n, url.format(n)) for n in range(20)]
    start = time.time()
    img_ids, urls = zip(*args)
    start = time.time()
    for _ in map(download_img, img_ids, urls):
        pass
    msg ='Spend time {:.3f}'
    print(msg.format(time.time() - start))

if __name__ == '__main__':
    main()

Và output vẫn là tuần tự công việc download image

Picture 0 saved!
Picture 1 saved!
...

Picture 19 saved!

Spend time 4.012

Trong cú pháp trên cách sử dụng map để thay thế cho for hơi khó hiểu. Tuy nhiên nó lại là bước đầu để bạn chuyển dần sang xử lý đồng thời.

Scripts xử lý đồng thời.

Để làm được điều này bạn cần phải biết tới module concurrent.futures bạn có thể tham khảo chi tiết tại https://docs.python.org/3/library/concurrent.futures.html?highlight=concurrent%20futures#executor-objects

Mình sẽ thay thể map bằng concurrency như sau

for _ in map(download_img, img_ids, urls):
    pass

Bằng

from concurrent.futures import ThreadPoolExecutor
...

with ThreadPoolExecutor(max_workers=20) as executor:
    executor.map(download_img, img_ids, urls)

function chi tiết như sau

import urllib.request
import time
from concurrent.futures import ThreadPoolExecutor

def download_img(img_id, url):
    res = urllib.request.urlopen(url)
    pic = res.read()
    with open(f'./{img_id}.jpg', 'wb') as f:
        f.write(pic)
    print(f'Picture {img_id} saved!')

def main():
    url = 'https://picsum.photos/id/{}/200/300'
    args = [(n, url.format(n)) for n in range(20)]
    start = time.time()
    img_ids, urls = zip(*args)
    start = time.time()
    with ThreadPoolExecutor(max_workers=20) as executor:
        executor.map(download_img, img_ids, urls)

    msg ='Spend time {:.3f}'
    print(msg.format(time.time() - start))

if __name__ == '__main__':
    main()

Output nhận được sẽ như sau

Picture 9 saved!
Picture 2 saved!
...
Picture 10 saved!

Spend time 0.209

Kết quả nhận được hoàn toàn khác xa lúc trước. Nếu xử lý tuần tự thì công việc download 20 images sẽ phải mất tới hơn 4s. Tuy nhiên nếu chuyển sang xử lý đồng thời kết quả chưa tới 1s. Thư viện ThreadPoolExecutor đã tận dụng 20 workers của cpu để cùng lúc xử lý song song 20 công việc đó. Output in ra màn hình thứ tự các bức ảnh tải về cũng ko còn theo tuần tự mà là bức ảnh nào được tải về trước sẽ được print ra đầu tiên.

Điều này tưởng như có vẻ là tuyệt vời tuy nhiên nó lại có 1 nhược điểm khá lớn. Khi sử dụng các thread để thực thi các công việc song song, nhưng các công việc đó lại được xử lý chỉ trong 1 core Nếu bạn cần nhiều hơn các công việc xử lý song song điều này có thể dẫn tới tình trạng cpu bị nóng và tê liệt. Rất may python đã cung cấp sẵn 1 thư viện khác thay thế cho nhược điểm đó

ProcessPoolExecutor. Thư viện này sẽ xử lý các công việc song song trên nhiều cpu. Thư viện này giải quyết được vấn đề của thread và nó vẫn ko phải là hoàn hảo. Trong python 1 process có thể chạy với nhiều thread, Để start 1 process nó cần nhiều hơn các tài nguyên xử lý. Bài sau mình sẽ mô tả chi tiết hơn về thư viện ProcessPoolExecutor này.