Giới thiệu

Máy tính đã phát triển theo thời gian và ngày càng có nhiều cách để làm cho máy tính chạy nhanh hơn. Điều gì sẽ xảy ra nếu thay vì thực hiện một lệnh duy nhất tại một thời điểm, chúng ta cũng có thể thực hiện nhiều lệnh cùng một lúc? Điều này có nghĩa là hiệu suất của hệ thống sẽ tăng lên đáng kể.

Thông qua đồng thời, chúng tôi có thể đạt được điều này và các chương trình Python của chúng tôi sẽ có thể xử lý nhiều yêu cầu hơn cùng một lúc và theo thời gian dẫn đến tăng hiệu suất ấn tượng.

Trong bài viết này, chúng ta sẽ thảo luận về tính đồng thời trong ngữ cảnh của lập trình Python, các dạng khác nhau của nó và chúng ta sẽ tăng tốc một chương trình đơn giản để xem hiệu suất đạt được trong thực tế.

Concurrency là gì?

Khi hai hoặc nhiều sự kiện đồng thời nó có nghĩa là chúng đang xảy ra cùng một lúc. Trong cuộc sống thực, sự đồng thời là phổ biến vì rất nhiều thứ xảy ra cùng một lúc. Trong máy tính, mọi thứ có một chút khác biệt khi nói đến tính đồng thời.

Trong máy tính, đồng thời là việc máy tính thực hiện các công việc hoặc nhiệm vụ cùng một lúc. Thông thường, một máy tính thực hiện một phần công việc khi những người khác đợi đến lượt của họ, sau khi hoàn thành, tài nguyên sẽ được giải phóng và phần công việc tiếp theo bắt đầu thực hiện. Đây không phải là trường hợp khi triển khai đồng thời vì các phần công việc được thực hiện không phải lúc nào cũng phải đợi người khác hoàn thành. Chúng được thực hiện cùng một lúc.

Đồng thời so với Song song

Chúng ta đã định nghĩa đồng thời là việc thực thi các tác vụ cùng một lúc, nhưng nó so sánh với song song như thế nào, và nó là gì?

Tính song song đạt được khi nhiều phép tính hoặc thao tác được thực hiện cùng lúc hoặc song song với mục tiêu tăng tốc quá trình tính toán.

Cả đồng thời và song song đều liên quan đến việc thực hiện nhiều tác vụ đồng thời, nhưng điều làm chúng khác biệt là thực tế là mặc dù đồng thời chỉ diễn ra trong một bộ xử lý, song song đạt được thông qua việc sử dụng nhiều CPU để thực hiện các tác vụ song song.

Chủ đề so với Quy trình và Nhiệm vụ

Nói chung, các luồng, quy trình và nhiệm vụ có thể đề cập đến các phần hoặc đơn vị công việc. Tuy nhiên, về chi tiết chúng không quá giống nhau.

Một luồng là đơn vị thực thi nhỏ nhất có thể được thực hiện trên máy tính. Các luồng tồn tại như các phần của một quy trình và thường không độc lập với nhau, có nghĩa là chúng chia sẻ dữ liệu và bộ nhớ với các luồng khác trong cùng một quy trình. Chủ đề đôi khi cũng được gọi là quy trình nhẹ.

Ví dụ: trong một ứng dụng xử lý tài liệu, một luồng có thể chịu trách nhiệm định dạng văn bản và một luồng khác xử lý việc lưu tự động, trong khi một luồng khác thực hiện kiểm tra chính tả.

Quy trình là một công việc hoặc một phiên bản của chương trình được tính toán có thể được thực thi. Khi chúng ta viết và thực thi mã, một quy trình được tạo ra để thực thi tất cả các tác vụ mà chúng ta đã hướng dẫn máy tính thực hiện thông qua mã của chúng ta. Một tiến trình có thể có một luồng chính duy nhất hoặc có nhiều luồng bên trong nó, mỗi luồng có ngăn xếp, thanh ghi và bộ đếm chương trình riêng. Nhưng tất cả chúng đều chia sẻ mã, dữ liệu và bộ nhớ.

Một số điểm khác biệt phổ biến giữa quy trình và luồng là:

  • Các quy trình hoạt động riêng lẻ trong khi các luồng có thể truy cập dữ liệu của các luồng khác
  • Nếu một luồng trong một quy trình bị chặn, các luồng khác có thể tiếp tục thực thi, trong khi một quy trình bị chặn sẽ tạm dừng việc thực thi các quy trình khác trong hàng đợi
  • Trong khi các luồng chia sẻ bộ nhớ với các luồng khác, các tiến trình thì không và mỗi tiến trình có cấp phát bộ nhớ riêng.

Một nhiệm vụ chỉ đơn giản là một tập hợp các lệnh chương trình được tải trong bộ nhớ.

Đa luồng so với Đa xử lý so với Asyncio

Sau khi khám phá các luồng và quy trình, bây giờ chúng ta hãy nghiên cứu sâu hơn về các cách khác nhau mà máy tính thực thi đồng thời.

Đa luồng đề cập đến khả năng của CPU để thực thi nhiều luồng đồng thời. Ý tưởng ở đây là chia một quá trình thành nhiều luồng khác nhau có thể được thực hiện song song hoặc cùng một lúc. Sự phân chia nhiệm vụ này giúp tăng cường tốc độ thực hiện của toàn bộ quy trình. Ví dụ, trong một trình xử lý văn bản như MS Word, rất nhiều thứ sẽ xảy ra khi sử dụng.

Đa luồng sẽ cho phép chương trình tự động lưu nội dung đang được viết, thực hiện kiểm tra chính tả cho nội dung và cũng có thể định dạng nội dung. Thông qua đa luồng, tất cả điều này có thể diễn ra đồng thời và người dùng không phải hoàn thành tài liệu trước để quá trình lưu diễn ra hoặc kiểm tra chính tả diễn ra.

Chỉ một bộ xử lý tham gia trong quá trình đa luồng và hệ điều hành quyết định thời điểm chuyển đổi các tác vụ trong bộ xử lý hiện tại, các tác vụ này có thể nằm ngoài quy trình hoặc chương trình hiện tại đang được thực thi trong bộ xử lý của chúng tôi.

Mặt khác, đa xử lý liên quan đến việc sử dụng hai hoặc nhiều đơn vị bộ xử lý trên máy tính để đạt được tính song song. Python thực hiện đa xử lý bằng cách tạo các quy trình khác nhau cho các chương trình khác nhau, với mỗi chương trình có phiên bản trình thông dịch Python riêng để chạy và cấp phát bộ nhớ để sử dụng trong quá trình thực thi.

AsyncIO hoặc IO không đồng bộ là một mô hình mới được giới thiệu trong Python 3 với mục đích viết mã đồng thời bằng cách sử dụng cú pháp async / await. Nó là tốt nhất cho các mục đích mạng cấp cao và có ràng buộc IO.

Khi nào sử dụng Concurrency

Ưu điểm của đồng thời được khai thác tốt nhất khi giải quyết các vấn đề ràng buộc CPU hoặc IO.

Các vấn đề liên quan đến CPU liên quan đến các chương trình thực hiện nhiều tính toán mà không yêu cầu mạng hoặc phương tiện lưu trữ và chỉ bị giới hạn bởi khả năng của CPU.

Các vấn đề liên quan đến IO liên quan đến các chương trình dựa vào tài nguyên đầu vào / đầu ra mà đôi khi có thể chậm hơn CPU và thường được sử dụng, do đó, chương trình phải đợi tác vụ hiện tại giải phóng tài nguyên I / O.

Tốt nhất là viết mã đồng thời khi tài nguyên CPU hoặc I / O bị hạn chế và bạn muốn tăng tốc chương trình của mình.

Cách sử dụng Concurrency

Trong ví dụ trình diễn của chúng tôi, chúng tôi sẽ giải quyết một vấn đề ràng buộc I / O phổ biến, đó là tải xuống tệp qua mạng. Chúng tôi sẽ viết mã không đồng thời và mã đồng thời và so sánh thời gian hoàn thành mỗi chương trình.

Chúng tôi sẽ tải xuống hình ảnh từ Imgur thông qua API của họ. Trước tiên, chúng ta cần tạo một tài khoản và sau đó Đăng ký ứng dụng demo của chúng tôi để truy cập API và tải xuống một số hình ảnh.

Sau khi ứng dụng của chúng tôi được thiết lập trên Imgur, chúng tôi sẽ nhận được mã định danh khách hàng và bí mật ứng dụng khách mà chúng tôi sẽ sử dụng để truy cập API. Chúng tôi sẽ lưu thông tin đăng nhập trong một .env kể từ Pipenv tự động tải các biến từ .env.

Tập lệnh đồng bộ

Với những chi tiết đó, chúng tôi có thể tạo tập lệnh đầu tiên của mình. Tập lệnh đầu tiên sẽ chỉ cần tải xuống một loạt hình ảnh vào downloads:

import os
from urllib import request
from imgurpython import ImgurClient
import timeit

client_secret = os.getenv("CLIENT_SECRET")
client_id = os.getenv("CLIENT_ID")

client = ImgurClient(client_id, client_secret)

def download_image(link):
    filename = link.split("https://ezerror.com/")[3].split('.')[0]
    fileformat = link.split("https://ezerror.com/")[3].split('.')[1]
    request.urlretrieve(link, "downloads/{}.{}".format(filename, fileformat))
    print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))

def main():
    images = client.get_album_images('PdA9Amq')
    for image in images:
        download_image(image.link)

if __name__ == "__main__":
    print("Time taken to download images synchronously: {}".format(timeit.Timer(main).timeit(number=1)))

Trong tập lệnh này, chúng tôi chuyển mã nhận dạng anbom Imgur và sau đó tải xuống tất cả các hình ảnh trong anbom đó bằng chức năng get_album_images(). Điều này cung cấp cho chúng tôi một danh sách các hình ảnh và sau đó chúng tôi sử dụng chức năng của mình để tải xuống các hình ảnh và lưu chúng vào một thư mục cục bộ.

Ví dụ đơn giản này hoàn thành công việc. Chúng tôi có thể tải xuống hình ảnh từ Imgur nhưng nó không hoạt động đồng thời. Nó chỉ tải một hình ảnh tại một thời điểm trước khi chuyển sang hình ảnh tiếp theo. Trên máy của tôi, tập lệnh mất 48 giây để tải hình ảnh xuống.

Tối ưu hóa với Đa luồng

Bây giờ chúng ta hãy tạo mã của chúng ta đồng thời bằng cách sử dụng Đa luồng và xem nó hoạt động như thế nào:

# previous imports from synchronous version are maintained
import threading
from concurrent.futures import ThreadPoolExecutor

# Imgur client setup remains the same as in the synchronous version

# download_image() function remains the same as in the synchronous

def download_album(album_id):
    images = client.get_album_images(album_id)
    with ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(download_image, images)

def main():
    download_album('PdA9Amq')

if __name__ == "__main__":
    print("Time taken to download images using multithreading: {}".format(timeit.Timer(main).timeit(number=1)))

Trong ví dụ trên, chúng tôi tạo Threadpool và thiết lập 5 chủ đề khác nhau để tải hình ảnh từ thư viện của chúng tôi. Ghi nhớ các luồng thực thi trên một bộ xử lý duy nhất.

Phiên bản mã này của chúng tôi mất 19 giây. Tốc độ đó nhanh hơn gần ba lần so với phiên bản đồng bộ của tập lệnh.

Tối ưu hóa với Đa xử lý

Bây giờ chúng tôi sẽ thực hiện Đa xử lý trên một số CPU cho cùng một tập lệnh để xem nó hoạt động như thế nào:

# previous imports from synchronous version remain
import multiprocessing

# Imgur client setup remains the same as in the synchronous version

# download_image() function remains the same as in the synchronous

def main():
    images = client.get_album_images('PdA9Amq')

    pool = multiprocessing.Pool(multiprocessing.cpu_count())
    result = pool.map(download_image, [image.link for image in images])

if __name__ == "__main__":
    print("Time taken to download images using multiprocessing: {}".format(timeit.Timer(main).timeit(number=1)))

Trong phiên bản này, chúng tôi tạo một nhóm chứa số lõi CPU trên máy của chúng tôi và sau đó ánh xạ chức năng của chúng tôi để tải xuống các hình ảnh trong nhóm. Điều này làm cho mã của chúng tôi chạy song song trên CPU của chúng tôi và phiên bản mã đa xử lý này của chúng tôi mất trung bình 14 giây sau nhiều lần chạy.

Điều này nhanh hơn một chút so với phiên bản sử dụng các chuỗi của chúng tôi và nhanh hơn đáng kể so với phiên bản không đồng thời của chúng tôi.

Tối ưu hóa với AsyncIO

Hãy để chúng tôi triển khai cùng một tập lệnh bằng cách sử dụng AsyncIO để xem nó hoạt động như thế nào:

# previous imports from synchronous version remain
import asyncio
import aiohttp

# Imgur client setup remains the same as in the synchronous version

async def download_image(link, session):
    """
    Function to download an image from a link provided.
    """
    filename = link.split("https://ezerror.com/")[3].split('.')[0]
    fileformat = link.split("https://ezerror.com/")[3].split('.')[1]

    async with session.get(link) as response:
        with open("downloads/{}.{}".format(filename, fileformat), 'wb') as fd:
            async for data in response.content.iter_chunked(1024):
                fd.write(data)

    print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))

async def main():
    images = client.get_album_images('PdA9Amq')

    async with aiohttp.ClientSession() as session:
        tasks = [download_image(image.link, session) for image in images]

        return await asyncio.gather(*tasks)

if __name__ == "__main__":
    start_time = timeit.default_timer()

    loop = asyncio.get_event_loop()
    results = loop.run_until_complete(main())

    time_taken = timeit.default_timer() - start_time

    print("Time taken to download images using AsyncIO: {}".format(time_taken))

Có một số thay đổi nổi bật trong tập lệnh mới của chúng tôi. Đầu tiên, chúng tôi không còn sử dụng requests để tải xuống hình ảnh của chúng tôi, nhưng thay vào đó chúng tôi sử dụng aiohttp. Lý do cho điều này là requests không tương thích với AsyncIO vì nó sử dụng Python httpsockets.

Về bản chất, các socket bị chặn, tức là chúng không thể bị tạm dừng và việc thực thi vẫn tiếp tục sau đó. aiohttp giải quyết vấn đề này và giúp chúng tôi đạt được mã thực sự không đồng bộ.

Từ khóa async chỉ ra rằng chức năng của chúng tôi là một quy trình điều tra (Quy trình hợp tác), là một đoạn mã có thể bị tạm dừng và tiếp tục. Coroutines hợp tác đa nhiệm, có nghĩa là họ chọn thời điểm tạm dừng và để người khác thực hiện.

Chúng tôi tạo một nhóm nơi chúng tôi tạo một hàng đợi gồm tất cả các liên kết đến những hình ảnh mà chúng tôi muốn tải xuống. Quy trình đăng ký của chúng tôi được bắt đầu bằng cách đưa nó vào vòng lặp sự kiện và thực hiện cho đến khi hoàn thành.

Sau vài lần chạy tập lệnh này, AsyncIO phiên bản này mất trung bình 14 giây để tải các hình ảnh trong album. Điều này nhanh hơn đáng kể so với các phiên bản mã đa luồng và đồng bộ, và khá giống với phiên bản đa xử lý.

So sánh hiệu suất

Đồng bộ Đa luồng Đa xử lý Asyncio
48s 19s 14s 14s

Sự kết luận

Trong bài đăng này, chúng tôi đã đề cập đến đồng thời và cách nó so sánh với song song. Chúng tôi cũng đã khám phá các phương pháp khác nhau mà chúng tôi có thể sử dụng để triển khai đồng thời trong mã Python của mình, bao gồm đa luồng và đa xử lý, đồng thời cũng thảo luận về sự khác biệt của chúng.

Từ các ví dụ trên, chúng ta có thể thấy cách đồng thời giúp mã của chúng ta chạy nhanh hơn so với theo cách đồng bộ. Theo nguyên tắc chung, Đa xử lý phù hợp nhất cho các tác vụ có sự ràng buộc của CPU trong khi Đa luồng là tốt nhất cho các tác vụ có ràng buộc I / O.

Mã nguồn cho bài đăng này có sẵn trên GitHub để tham khảo.