Thay đổi biến toàn cục trong thread python

Phần lớn các máy tính của chúng ta hiện nay có kiến ​​trúc đa lõi và các thuật ngữ như đa luồng thường vang lên trong tai chúng ta như một cách để cải thiện hiệu quả xử lý của các ứng dụng. Python cung cấp một số công cụ để tính toán song song, nhưng chúng thường không được biết đến nhiều. Hãy cùng khám phá những bí mật của họ trong bài viết này

Ảnh của Mathew Schwartz trên Bapt

Đầu tiên là một lời nhắc nhở nhỏ về luồng. một chủ đề là gì? . Khi bạn chạy hai chương trình trên máy tính của mình, bạn thực sự tạo ra hai quy trình. Mỗi người trong số họ có một bộ hướng dẫn [mở trình duyệt của bạn hoặc tăng âm lượng] mà bộ lập lịch muốn đọc [người trọng tài quyết định nội dung sẽ cung cấp cho bộ xử lý]. Điểm đặc biệt của các luồng so với các quy trình là chúng có thể chia sẻ các biến

Đối với chúng tôi về mã hóa, khi chúng tôi chạy hai luồng, chúng tôi cho phép hai đoạn mã chạy cùng một lúc. Tuy nhiên, nó khác với việc chỉ thực thi hai chương trình cùng một lúc vì các luồng cho chúng ta nhiều quyền kiểm soát hơn. Ví dụ: chúng ta có thể chia sẻ một số biến giữa các luồng hoặc chúng ta có thể đợi các luồng kết thúc, hợp nhất các kết quả và tiếp tục với phần còn lại của mã. Nó là một công cụ rất mạnh có thể cho phép tính toán nhanh hơn hoặc khả năng xử lý các sự kiện đồng thời [nghĩ về rô bốt có nhiều dữ liệu cảm biến để xử lý]

Hãy lạc đề một chút và phân tích các khả năng khác nhau mà Python cung cấp để chạy tính toán song song. Ba người đoạt giải là. Chủ đề, nhóm chủ đề và đa xử lý

Để rõ ràng, trước tiên hãy giới thiệu chức năng mà chúng tôi muốn song song hóa. Chức năng ngủ, với mục đích là… ngủ

  1. xâu chuỗi. công cụ cơ bản nhất mà Python có thể cung cấp cho luồng

Thư viện Python cho phép chúng ta tạo các Chủ đề theo cách thủ công, mà chúng ta có thể chỉ định mục tiêu [hàm mà chúng ta muốn thực thi trong chuỗi này] và các đối số của nó. Giao diện cũng bao gồm chức năng bắt đầu cũng như chức năng nối, chức năng này sẽ đợi cho đến khi quá trình thực thi luồng kết thúc. Tham gia các luồng thường được mong muốn khi chúng ta muốn khai thác các kết quả được trả về bởi luồng. Nhưng luồng cơ bản. Chủ đề khá hạn chế theo nghĩa là nó không cho phép chúng ta truy cập các biến được trả về bởi hàm ngủ

2. nhóm chủ đề. [và đó là thư viện đồng thời. tương lai]

Trình thực thi nhóm luồng cung cấp một bộ giao diện hoàn chỉnh hơn cho luồng. Tuy nhiên, triển khai cơ bản của nó vẫn sử dụng thư viện Threading, mang lại những ưu điểm và nhược điểm giống như tùy chọn trước đó. Về sự khác biệt về giao diện, nó đề xuất khái niệm về tương lai, có vẻ quen thuộc với người dùng C++ 14. Ưu điểm lớn nhất của hợp đồng tương lai đối với chúng ta ở đây là nó cho phép lấy biến được trả về bởi hàm mà chúng ta đang phân luồng bằng giao diện result[]

3. Đa xử lý [từ thư viện đa xử lý]

Đa xử lý là thư viện đầy đủ nhất có thể cung cấp khả năng phân luồng. Sự khác biệt chính với hai cái khác ngoài việc cung cấp nhiều giao diện hơn, là khả năng tuần tự hóa và hủy tuần tự hóa dữ liệu bằng thư viện của bên thứ ba có tên là dưa chua. Tuần tự hóa là khả năng chuyển đổi các loại dữ liệu [int, mảng, v.v. ] thành nhị phân, chuỗi 0 và 1. Để làm như vậy, cần phải có khả năng gửi dữ liệu bằng các giao thức [tcp/ip, http, udp…] vì các giao thức này không phụ thuộc vào loại dữ liệu chúng tôi sử dụng. người gửi có thể đang chạy mã của mình bằng Python trong khi người nhận có thể sử dụng C++. Trong trường hợp đa xử lý, tuần tự hóa xảy ra khi chúng ta truyền hàm cũng như các đối số cho đối tượng nhóm. Điều này cho phép chúng tôi làm điều gì đó đáng kinh ngạc. gửi chủ đề này để được thực hiện trên. máy tính khác. Do đó, thư viện đa xử lý nhằm mục đích cho phép tính toán được chia sẻ trên nhiều máy tính.

Cần lưu ý, thư viện đa xử lý cung cấp các giao diện áp dụng [đồng bộ hóa] và apply_async, viết tắt của đồng bộ và không đồng bộ. Trong trường hợp đầu tiên, các luồng buộc phải quay lại theo thứ tự như khi chúng được khởi chạy trong khi ở trường hợp thứ hai, các luồng quay lại ngay sau khi chúng kết thúc. apply_async cung cấp một đối số bổ sung "gọi lại" cung cấp khả năng thực thi một chức năng khi luồng trả về [ví dụ: để lưu trữ kết quả]

Bây giờ là lúc để so sánh kết quả của các phương pháp phân luồng khác nhau. Trước tiên, chúng tôi sử dụng chức năng “ngủ” đã đề cập trước đây để đánh giá kết quả

Chúng tôi cũng tính toán tuần tự chức năng ngủ để cung cấp kết quả cơ sở để so sánh. Ngủ trong 2 giây mỗi lần, chúng tôi nhận được tổng thời gian tính toán cho cách tiếp cận tuần tự có vẻ hợp lý. Chúng tôi nhận được thời gian tính toán là 2 giây cho các phương pháp phân luồng và đa xử lý, điều đó có nghĩa là tất cả các luồng có thể chạy song song thành công, nhưng chúng tôi nhận được 4 giây cho bộ thực thi nhóm luồng, cho thấy rằng có một số chi phí tính toán bổ sung trong quá trình đó. Bây giờ điều này rất hay chỉ có 3 luồng chạy song song, nhưng chúng tôi có thể muốn chạy hơn một nghìn luồng. Hãy xem nó diễn ra như thế nào với mức độ khó cao hơn, chẳng hạn như 100 chủ đề

Đối với luồng và tổng hợp luồng, kết quả không thay đổi từ 3 đến 100 luồng. Tuy nhiên, đối với phương pháp đa xử lý, thời gian tính toán đã tăng lên 50 giây. Để hiểu chuyện gì đang xảy ra, chúng ta hãy xem lời cảnh báo mà chúng ta đã đặt ra một cách chu đáo. “Cảnh báo, đang cố gắng tạo nhiều luồng hơn số lõi hiện có”. Như vậy, đa xử lý sẽ cố gắng gửi các luồng đến các lõi có sẵn [4 trong trường hợp của tôi] nhưng nếu không có lõi nào, chúng ta có thể đoán rằng việc tính toán các luồng được xếp hàng và do đó trở thành tuần tự

Chúng tôi có thể kết thúc chủ đề và kết thúc bằng, đa xử lý thật tệ, thư viện luồng thì tuyệt vời. Nhưng đợi chút. Cho đến bây giờ, những gì chúng tôi đã làm trong chức năng luồng đó chỉ ở trạng thái ngủ, có nghĩa là về mặt hướng dẫn xử lý. không làm gì cả. Bây giờ, điều gì sẽ xảy ra nếu chúng ta có một luồng tham lam hơn nhiều đối với tài nguyên máy tính. Hãy lấy ví dụ về chức năng đếm

Chúng tôi sử dụng 4 luồng [số lõi tôi có trên máy tính cá nhân của mình] và có thể thấy các kết quả sau

Ở đây, cách tiếp cận đa xử lý nhanh hơn ít nhất 4 lần so với hai cách kia. Nhưng quan trọng hơn, phân luồng chiếm gần như nhiều thời gian như phương pháp tuần tự trong khi phương pháp tổng hợp luồng mất gấp đôi thời gian, khi mục tiêu ban đầu là tối ưu hóa thời gian tính toán

Để hiểu tại sao điều này xảy ra, chúng ta cần xem GIL [Khóa phiên dịch toàn cầu]. Python là ngôn ngữ được giải thích trong khi C hoặc C ++ là ngôn ngữ được biên dịch. Quá trình biên dịch thực hiện là chuyển mã viết thành ngôn ngữ mà bộ xử lý có thể hiểu được. nhị phân. Vì vậy, khi mã được thực thi, nó sẽ được bộ lập lịch đọc trực tiếp và sau đó là bộ xử lý. Tuy nhiên, trong trường hợp ngôn ngữ được giải thích, khi khởi chạy chương trình, mã vẫn được viết ở dạng người có thể đọc được, đây là cú pháp python. Để bộ xử lý đọc được, nó phải được giải thích trong thời gian chạy bởi cái gọi là trình thông dịch Python. Tuy nhiên, một vấn đề phát sinh khi luồng. Trình thông dịch Python không cho phép thông dịch nhiều luồng cùng một lúc và do đó có một khóa, GIL để thực thi điều đó. Hãy xem một sơ đồ để hiểu tình hình tốt hơn

Trong trường hợp đó, GIL hoạt động như một nút cổ chai và vô hiệu hóa các lợi thế của luồng. Điều đó giải thích khá rõ lý do tại sao các trường hợp Phân luồng và Phân luồng nhóm trong phân tích so sánh lại hoạt động kém như vậy. Mỗi luồng đang chiến đấu để có quyền truy cập qua trình thông dịch Python mỗi khi số lượng tương ứng của chúng cần được tăng lên và điều này là hàng tỷ lần, trong khi khi các luồng đang ngủ, trình thông dịch Python chỉ được yêu cầu một lần trong toàn bộ thời gian tồn tại của luồng. Nếu đúng như vậy, tại sao phương pháp đa xử lý lại mang lại kết quả tốt hơn đáng kể?

Do đó, nút cổ chai biến mất và toàn bộ tiềm năng phân luồng có thể được mở khóa

Điều đáng nói là đa xử lý vẫn có một số nhược điểm do quá trình tuần tự hóa. không phải mọi thứ đều có thể được tuần tự hóa. Đặc biệt, trình tạo python hoặc bất kỳ thứ gì có con trỏ như hành vi không thể được tuần tự hóa [điều này có vẻ khá bình thường]. Giả sử chúng ta có và đối tượng "tác nhân" với một số nội dung không xác định [ví dụ ở đây là một số đối tượng pytorch], sau đó chúng ta gặp lỗi

Chúng ta có thể giải thích sự khác biệt giữa Phân luồng và Đa xử lý về mặt hiệu quả tính toán. Trong phần thứ hai này, chúng ta có thể xem xét kỹ hơn sự khác biệt chính về cách quản lý các tài nguyên và biến, đặc biệt là đối với các tài nguyên được chia sẻ. Hãy xem đoạn mã dưới đây làm cho các luồng sử dụng một biến toàn cục

Mã này chạy hoàn toàn bình thường mặc dù chúng tôi đã gửi các đoạn mã để được đánh giá bởi các bộ xử lý khác nhau [các tiến trình con] truy cập vào cùng một biến toàn cục. Điều đó có nghĩa là tất cả các trình thông dịch python đều có thể truy cập biến toàn cục này. Vì vậy, các trình thông dịch python có thể giao tiếp với nhau trong thời gian chạy không?

Mục đích của mã này là cố ý thay đổi giá trị của biến toàn cục trong thời gian chạy từ các luồng khác nhau. Không chỉ điều này, mà họ còn làm điều đó với các thời gian khác nhau [quan sát giấc ngủ với các đầu vào khác nhau]

Nếu biến này thực sự là toàn cầu, khi luồng thứ hai xuất giá trị của biến toàn cục, thì nó đã được tăng lên một chút. Tuy nhiên, chúng tôi thấy rằng giá trị này luôn có giá trị 1 khi luồng đang in nó. Vì vậy, bây giờ chúng ta có một ý tưởng tốt hơn về những gì đang xảy ra. Khi luồng được tạo [và cùng với nó là trình thông dịch python], tất cả các biến được sao chép sang luồng mới, không có ngoại lệ nào được thực hiện với các biến toàn cục. Theo một nghĩa nào đó, tất cả các luồng đều là bản sao giống hệt nhau của quy trình lớn hơn mà bạn đang chạy với các đối số hơi khác nhau được truyền cho luồng. Hiện đa xử lý cung cấp các tùy chọn khác nhau liên quan đến cách các quy trình con được tạo. Chúng ta có thể chọn giữa ba phương thức bắt đầu, đó là spawn, fork và forkserver. Chúng tôi sẽ phân tích hai cái đầu tiên. Theo tài liệu Python

Vì vậy, sự khác biệt chính nằm ở những biến nào được kế thừa từ quy trình cha khi tạo quy trình con. Hãy xem xét mã đã được giới thiệu ở trên. Nếu quan sát kỹ, bạn có thể nhận thấy rằng phương thức bắt đầu được chỉ định là “fork”. Hãy xem những gì mã này thực sự xuất ra

Không có gì đặc biệt để xem ở đây, chúng tôi biết rằng tiến trình con có thể truy cập vào biến toàn cục vì nó đã sao chép nó. Bây giờ hãy xem điều gì sẽ xảy ra khi chúng ta chuyển sang “đẻ trứng”

Tôi có thể nhìn thấy vẻ ngạc nhiên trong mắt bạn, không, bạn không nằm mơ, bạn đã nhìn thấy điều này nhiều lần. Lệnh “in [“Chỉ nên xem cái này một lần”]” được tạo ngay từ đầu chương trình và hoàn toàn nằm ngoài vòng lặp nơi các chuỗi được gửi đi. Tuy nhiên, điều này đã được in 4 lần. Vậy chuyện gì đang xảy ra? . Những gì bạn cần hiểu từ điều này được kế thừa = được sao chép và not_inherited = được đánh giá lại. Vì vậy, khi chúng tôi đang chọn "sinh sản", mọi hướng dẫn của quy trình đều được diễn giải lại, hàm gọi cũng như cấp phát bộ nhớ biến
Bây giờ bạn có thể nhận thấy “if __name__ == ‘__main__’. " tuyên bố. Điều này biểu thị cho trình thông dịch biết rằng bất cứ thứ gì bên trong đều thuộc về chính và do đó sẽ được kế thừa bởi tiến trình con. Mọi thứ không có trong câu lệnh đó sẽ được đánh giá lại cho từng tiến trình con. Điều đó có nghĩa là theo mặc định, “spawn” đang cố gắng đánh giá lại mọi thứ, nhưng chúng tôi có quyền kiểm soát những gì được kế thừa, trong khi đối với “fork”, mọi biến được kế thừa theo mặc định
Bạn có thể thắc mắc tại sao điều này lại quan trọng, trong trường hợp biến toàn cục của chúng ta, nó không thay đổi nhiều. Nhưng đối với một số đối tượng, hàm tạo bản sao [khi bạn đang sử dụng toán tử =] có thể không được xác định rõ, điều này khiến việc sử dụng phương thức fork không an toàn trong một số trường hợp. Vì lý do đó, có thể thấy trong tài liệu về python rằng phương thức spawn đang trở thành phương thức bắt đầu mặc định trên tất cả các nền tảng

Bạn có thể thắc mắc rằng đây là một hạn chế khá nặng nề, nếu các luồng được tạo bởi đa xử lý bị đóng hoàn toàn, chúng tôi sẽ mất khả năng có một mức độ đồng bộ hóa giữa các luồng. Điều này không hoàn toàn đúng vì thư viện cung cấp cho chúng tôi một số công cụ để thực hiện chính xác điều đó. ống

Kết quả. “Ngủ cho. 2. 00”, có nghĩa là luồng thực sự đợi để nhận dữ liệu do quy trình cha cung cấp trước khi tiếp tục. Theo một cách nào đó, đường ống tương đương với hợp đồng tương lai của C++, nơi chúng tôi có thể đợi thu được một số dữ liệu được cung cấp bởi một chuỗi khác. Nhưng tất nhiên trong trường hợp này, vì quá trình đa xử lý có thể xảy ra trên các máy tính khác nhau, nên dữ liệu được gửi qua các đường ống cũng cần được tuần tự hóa dưới mui xe

Chúng tôi đã nói về quản lý tài nguyên cho đa xử lý. Đối với thư viện luồng [và trình thực thi nhóm luồng], mọi thứ hơi khác một chút vì chúng tôi chỉ có một trình thông dịch python. Hãy xem một ví dụ

Chúng tôi khởi chạy 4 luồng cùng lúc, với độ trễ nhỏ khác nhau để mỗi luồng thực sự bắt đầu đếm

Trái ngược với đa xử lý, biến toàn cục ở đây được chia sẻ giữa các luồng và không giữ bản sao cục bộ. Nếu bạn đã quen với các chủ đề bị thao túng, có lẽ bạn sẽ kinh hoàng với đoạn mã trên, hét lên các thuật ngữ như “điều kiện cuộc đua” hoặc “khóa”. Khóa [có thể được chia sẻ giữa các luồng], là một loại trình giữ cổng chỉ cho phép một luồng mở khóa cánh cửa thực thi mã cùng một lúc, để ngăn các biến được truy cập hoặc sửa đổi cùng một lúc. Đợi đã, chúng tôi thực sự đã nghe điều đó ở đâu đó. GIL [Khóa phiên dịch toàn cầu]

Vì vậy, python đã có cơ chế khóa để ngăn hai luồng thực thi mã cùng một lúc. Tin tốt, đoạn mã trên có thể không quá tệ. Hãy chứng minh điều đó bằng cách in tổng số được tạo sau khi thực hiện ba luồng [và loại bỏ các chế độ ngủ]. Vì GIL bảo vệ chúng ta khỏi các lệnh thực thi luồng cùng một lúc, bất kể luồng nào đang tăng biến toàn cục, chúng ta nên thêm 10e5 * 4 nhân 1 để có tổng cộng 4. 000. 000

Ok, chúng tôi cần một số lời giải thích ở đây. Đặc biệt là cách GIL thực sự hoạt động. Mô tả “chỉ cho phép một luồng chạy cùng lúc” có thể không đủ chính xác để giải thích tình huống. Nếu chúng ta đi sâu hơn một chút vào các chi tiết, chúng ta có thể thấy điều này. “Để hỗ trợ các chương trình Python đa luồng, trình thông dịch thường xuyên giải phóng và yêu cầu lại khóa — theo mặc định, cứ sau mười hướng dẫn mã byte”. Vì vậy, điều này không giống như việc chỉ cho phép đọc một dòng mã python thực tế cùng một lúc. Để hiểu đầy đủ điều này, chúng ta cần phải xuống một mức

Điều gì xảy ra khi bạn thực thi chương trình python? . Đối với python, mã trung gian này được gọi là bytecode. Có thể tìm thấy mô tả đầy đủ về các hoạt động mã byte tại đây. Mô-đun dis cho phép chúng ta xem bytecode trông như thế nào đối với một chức năng cụ thể. Nếu chúng ta cố gắng có một cái nhìn thoáng qua về một hàm cộng 1 vào một biến toàn cục

Để chỉ cần thêm một hằng số vào một biến, chúng ta cần 4 thao tác mã byte, tìm nạp các biến, cộng chúng lại với nhau và lưu trữ kết quả
Bây giờ chúng ta đã hiểu rõ hơn về hoạt động mã byte là gì, chúng ta có thể nhận thấy rằng GIL đi kèm với các cài đặt mà chúng ta có thể kiểm soát. hệ thống. setcheckinterval[] cho phép chúng tôi kiểm soát mọi mã byte mà chúng tôi muốn khóa GIL. Nhưng ngay cả khi chúng tôi đặt giá trị này thành 1 [GIL bị khóa mọi lệnh bytecode], điều này sẽ không thực sự giúp ích cho chúng tôi. Hãy phân tích điều gì xảy ra trong trường hợp chúng ta khóa GIL cứ sau 3 mã byte

Các số từ 1 đến 4 biểu thị thứ tự mà các nhóm mã byte được phép xử lý bởi GIL. Bây giờ hãy tưởng tượng biến toàn cục ban đầu có giá trị là 0. Trong mỗi luồng, chúng tôi đang cố gắng thêm 1 vào biến này. Khi kết thúc quá trình thực thi nhóm bytecode “1”, bản sao của biến mà nó nhận được từ LOAD_FAST đã tăng [INPLACE_ADD] lên 1. Nhưng bản thân biến toàn cục vẫn chưa được sửa đổi vì STORE_FAST chưa được thực thi. Bây giờ đến lượt chủ đề thứ hai. vì biến không được lưu trữ nên nó vẫn sẽ tạo một bản sao của biến toàn cục với giá trị 0 và tăng 1 cho nó. Bây giờ khi nhóm 3 được thực thi, global_variable cuối cùng được lưu trữ với giá trị 1. Nhưng như bạn có thể tưởng tượng, khi thực hiện nhóm 4, bản sao cục bộ cũng có giá trị 1 và biến toàn cục sẽ được lưu trữ một lần nữa với giá trị 1, trong khi chúng tôi mong đợi nó là 2. Tin xấu là, việc chúng ta khóa GIL thường xuyên như thế nào không quan trọng, miễn là một phần của mã byte tương đương “global_variable += 1” bị lẫn lộn, chúng ta có một điều kiện chạy đua

Vì vậy, GIL không đủ để bảo vệ các biến của chúng tôi và chúng tôi không có lựa chọn nào khác ngoài việc sử dụng các khóa để buộc nó trên trình thông dịch chỉ thực thi một số khối mã tại một thời điểm trên các luồng

Điều đó đã hoạt động đúng, các chuỗi được tính đến số chính xác, trong khi thực hiện công việc này song song. Tuy nhiên, nếu bạn nhìn vào tổng thời gian tính toán, thì đây là quá nhiều. Lấy và giải phóng các khóa thực sự tốn thời gian và khi chúng ta cần truy cập các biến thường xuyên như chúng ta làm ở đây, nó sẽ tích lũy để tạo thành thời gian tính toán rất nặng nề này

Bây giờ là lúc tổng kết kinh nghiệm của chúng tôi với tính toán song song trong Python

  • Chúng tôi có hai thư viện chính cho phép chúng tôi thực hiện tính toán song song. Luồng và đa xử lý
  • Global Interpreter Lock [GIL] hạn chế các chương trình python về hiệu quả tính toán song song, nhưng Multiprocessing vượt qua nó bằng cách tạo nhiều trình thông dịch
  • Đa xử lý có thể tận dụng tối đa kiến ​​trúc nhiều lõi và thậm chí song song hóa việc tính toán trên các máy tính khác nhau bằng cách tuần tự hóa/khử tuần tự hóa dữ liệu cần thiết của mỗi luồng. Tuy nhiên, nó tạo ra một bản sao hoặc đánh giá lại tài nguyên của môi trường. Mỗi luồng phát triển trong môi trường hạn chế của nó và không thể trao đổi với các luồng khác trừ khi sử dụng các công cụ cụ thể
  • Thư viện luồng làm cho các tiến trình con có thể truy cập và sửa đổi các biến giống nhau, tuy nhiên GIL không ngăn chặn các điều kiện chủng tộc và chúng tôi phải sử dụng khóa để ngăn điều này xảy ra

Vậy khi nào sử dụng đa xử lý và khi nào sử dụng Threading?

Chủ đề có thể thay đổi các biến toàn cục không?

Tuy nhiên, tất cả các luồng đều có quyền truy cập như nhau vào các biến toàn cục . Để tránh trường hợp nhiều luồng sửa đổi một biến đồng thời, bạn phải khai báo biến đó là có thể chia sẻ.

Là chủ đề biến toàn cầu Python

Nếu bạn khởi tạo nó một lần, và nếu bạn khởi tạo nó khi mô-đun được tải [điều đó có nghĩa là. trước khi nó có thể được truy cập từ các luồng khác], bạn sẽ không gặp vấn đề gì về độ an toàn của luồng . Không cần đồng bộ hóa.

Chủ Đề