paint-brush
Phá vỡ các tiên đề trong việc thực thi chương trìnhtừ tác giả@nekto0n
20,889 lượt đọc
20,889 lượt đọc

Phá vỡ các tiên đề trong việc thực thi chương trình

từ tác giả Nikita Vetoshkin9m2023/10/24
Read on Terminal Reader

dài quá đọc không nổi

Tác giả, một kỹ sư phần mềm dày dạn kinh nghiệm, chia sẻ những hiểu biết sâu sắc về hành trình của họ từ mã tuần tự đến hệ thống phân tán. Họ nhấn mạnh rằng việc áp dụng tính toán thực thi không tuần tự hóa, đa luồng và phân tán có thể giúp cải thiện hiệu suất và khả năng phục hồi. Mặc dù nó mang lại sự phức tạp nhưng đó là một hành trình khám phá và nâng cao khả năng phát triển phần mềm.
featured image - Phá vỡ các tiên đề trong việc thực thi chương trình
Nikita Vetoshkin HackerNoon profile picture


Phạm sai lầm mới

Tôi đã là kỹ sư phần mềm được khoảng 15 năm. Trong suốt sự nghiệp của mình, tôi đã học được rất nhiều điều và áp dụng những bài học này để thiết kế và triển khai (và đôi khi loại bỏ dần hoặc giữ nguyên) nhiều hệ thống phân tán. Trong quá trình đó, tôi đã phạm rất nhiều sai lầm và vẫn tiếp tục mắc phải chúng. Nhưng vì trọng tâm chính của tôi là độ tin cậy nên tôi đã nhìn lại kinh nghiệm của mình và cộng đồng để tìm cách giảm thiểu tần suất lỗi. Phương châm của tôi là: chúng ta nhất định phải cố gắng mắc những sai lầm mới (ít rõ ràng hơn, phức tạp hơn). Phạm sai lầm cũng được - đó là cách chúng ta học hỏi, lặp lại - thật đáng buồn và nản lòng.


Đó có lẽ là điều luôn mê hoặc tôi về toán học. Không chỉ vì nó trang nhã và súc tích mà còn vì tính logic chặt chẽ của nó giúp ngăn ngừa sai sót. Nó buộc bạn phải suy nghĩ về bối cảnh hiện tại của mình, những định đề và định lý nào bạn có thể dựa vào. Thực hiện theo các quy tắc này chứng tỏ là có hiệu quả, bạn sẽ nhận được kết quả chính xác. Đúng là khoa học máy tính là một nhánh của toán học. Nhưng những gì chúng tôi thường làm là công nghệ phần mềm, một lĩnh vực rất khác biệt. Chúng tôi áp dụng những thành tựu và khám phá của khoa học máy tính vào thực tiễn, tính đến những hạn chế về thời gian và nhu cầu kinh doanh. Blog này là một nỗ lực nhằm áp dụng lý luận bán toán học vào việc thiết kế và triển khai các chương trình máy tính. Chúng tôi sẽ đưa ra một mô hình với các chế độ thực thi khác nhau cung cấp một khuôn khổ để tránh nhiều lỗi lập trình.


Từ khởi đầu khiêm tốn

Khi học lập trình và thực hiện những bước thăm dò (hoặc táo bạo) đầu tiên, chúng ta thường bắt đầu với những điều đơn giản:


  • lập trình các vòng lặp, thực hiện số học cơ bản và in kết quả trong một thiết bị đầu cuối
  • giải các bài toán, có thể là trong một số môi trường chuyên biệt như MathCAD hoặc Mathematica


Chúng ta có được trí nhớ cơ bắp, học cú pháp ngôn ngữ và quan trọng nhất là chúng ta thay đổi cách suy nghĩ và lý luận. Chúng ta học cách đọc mã, đưa ra các giả định về cách nó được thực thi. Chúng ta hầu như không bao giờ bắt đầu bằng việc đọc một tiêu chuẩn ngôn ngữ và đọc kỹ phần “Mô hình bộ nhớ” của nó - bởi vì chúng ta chưa được trang bị đầy đủ để đánh giá cao và tận dụng những thứ đó. Chúng tôi thực hành thử và sai: chúng tôi đưa ra các lỗi logic và số học trong các chương trình đầu tiên của mình. Những lỗi này dạy chúng ta kiểm tra các giả định của mình: vòng lặp này có bất biến đúng không, chúng ta có thể so sánh chỉ mục và độ dài của phần tử mảng theo cách này không (bạn đặt -1 ở đâu)? Nhưng nếu chúng ta không thấy một số loại lỗi nào đó, thì đôi khi chúng ta ngầm tiếp thu một số lỗi. bất biến hệ thống thực thi và cung cấp cho chúng tôi.


Cụ thể là cái này:


Các dòng mã luôn được đánh giá theo cùng một thứ tự (được tuần tự hóa).

Định đề này cho phép chúng ta giả định rằng các mệnh đề tiếp theo là đúng (chúng ta sẽ không chứng minh chúng):


  • Thứ tự đánh giá không thay đổi giữa các lần thực hiện
  • các cuộc gọi hàm luôn trả về


Các tiên đề toán học cho phép rút ra và xây dựng các cấu trúc lớn hơn trên cơ sở vững chắc. Trong toán học, chúng ta có hình học Euclide với các tiên đề 4+1. Người cuối cùng nói:

các đường thẳng song song luôn song song, chúng không giao nhau hoặc phân kỳ


Trong nhiều thiên niên kỷ, các nhà toán học đã cố gắng chứng minh điều đó và rút ra nó từ bốn điều đầu tiên. Hóa ra điều đó là không thể. Chúng ta có thể thay thế định đề “đường song song” này bằng các lựa chọn thay thế và có được các loại hình học khác nhau (cụ thể là hyperbol và elip), mở ra những triển vọng mới và hóa ra có thể áp dụng được và hữu ích. Xét cho cùng, bề mặt hành tinh của chúng ta không bằng phẳng và chúng ta phải tính đến điều đó, ví dụ như trong phần mềm GPS và đường bay trên máy bay.

Sự cần thiết phải thay đổi

Nhưng trước đó, hãy dừng lại và đặt những câu hỏi mang tính kỹ thuật nhất: tại sao phải bận tâm? Nếu chương trình thực hiện đúng công việc của nó, dễ dàng hỗ trợ, duy trì và phát triển, thì tại sao ngay từ đầu chúng ta lại phải từ bỏ tính bất biến ấm cúng của việc thực thi tuần tự có thể dự đoán được này?


Tôi thấy hai câu trả lời. Đầu tiên là hiệu suất . Nếu chúng tôi có thể làm cho chương trình của mình chạy nhanh gấp đôi hoặc tương tự - yêu cầu một nửa phần cứng - thì đây là một thành tựu kỹ thuật. Nếu sử dụng cùng một lượng tài nguyên tính toán, chúng ta có thể nghiền nát dữ liệu gấp 2 lần (hoặc 3, 4, 5, 10 lần) - nó có thể mở ra các ứng dụng hoàn toàn mới của cùng một chương trình. Nó có thể chạy trên điện thoại di động trong túi của bạn thay vì máy chủ. Đôi khi chúng ta có thể tăng tốc bằng cách áp dụng các thuật toán thông minh hoặc viết lại bằng ngôn ngữ hiệu quả hơn. Đây là những lựa chọn đầu tiên của chúng tôi để khám phá, vâng. Nhưng họ có giới hạn. Kiến trúc hầu như luôn đánh bại việc triển khai. Định luật Moor gần đây hoạt động không tốt, hiệu suất của một CPU đang tăng chậm, hiệu suất RAM (chủ yếu là độ trễ) bị tụt lại phía sau. Vì vậy, một cách tự nhiên, các kỹ sư bắt đầu tìm kiếm các lựa chọn khác.


Xem xét thứ hai là độ tin cậy . Thiên nhiên là hỗn loạn, định luật thứ hai của nhiệt động lực học liên tục hoạt động chống lại mọi thứ chính xác, tuần tự và có thể lặp lại. Bit bị lật, vật liệu xuống cấp, mất điện, dây bị cắt ngăn cản việc thực thi chương trình của chúng tôi. Giữ sự trừu tượng tuần tự và lặp lại trở thành một công việc khó khăn. Nếu các chương trình của chúng tôi có thể tồn tại lâu hơn các lỗi phần mềm và phần cứng thì chúng tôi có thể cung cấp các dịch vụ có lợi thế kinh doanh cạnh tranh - đó là một nhiệm vụ kỹ thuật khác mà chúng tôi có thể bắt đầu giải quyết.


Được trang bị mục tiêu, chúng ta có thể bắt đầu thử nghiệm với các phương pháp tiếp cận không nối tiếp.


Chủ đề thực hiện

Chúng ta hãy xem đoạn mã giả này:


```

def fetch_coordinates(poi: str) -> Point:

def find_pois(center: Point, distance: int) -> List[str]:

def get_my_location() -> Point:


def fetch_coordinates(p) - Point:

def main():

me = get_my_location()

for point in find_pois(me, 500):
loc = fetch_coordinates(point)
sys.stdout.write(f“Name: {point} is at x={loc.x} y={loc.y}”)

Chúng ta có thể đọc mã từ đầu đến cuối và giả định một cách hợp lý rằng hàm `find_pois` sẽ được gọi sau `get_my_location`. Và chúng tôi sẽ tìm nạp và trả về tọa độ của POI đầu tiên sau khi tìm nạp POI tiếp theo. Những giả định đó là chính xác và cho phép xây dựng một mô hình, lý do trong đầu về chương trình.


Hãy tưởng tượng chúng ta có thể làm cho mã của mình thực thi không tuần tự. Có nhiều cách chúng ta có thể làm điều đó về mặt cú pháp. Chúng ta sẽ bỏ qua các thử nghiệm sắp xếp lại câu lệnh (đó là điều mà các trình biên dịch và CPU hiện đại thực hiện) và mở rộng ngôn ngữ của chúng ta để có thể thể hiện một chế độ thực thi hàm mới: kiêm nhiệm hoặc thậm chí song song quan đến các chức năng khác. Viết lại, chúng ta cần giới thiệu nhiều luồng thực thi. Các chức năng chương trình của chúng tôi thực thi trong một môi trường cụ thể (được hệ điều hành tạo và duy trì), hiện tại chúng tôi quan tâm đến bộ nhớ ảo có thể định địa chỉ và một luồng - một đơn vị lập lịch, thứ có thể được thực thi bởi CPU.


Các luồng có nhiều loại khác nhau: luồng POSIX, luồng xanh, coroutine, goroutine. Các chi tiết có thể khác nhau rất nhiều, nhưng tóm lại là một thứ có thể được thực thi. Nếu một số chức năng có thể chạy đồng thời thì mỗi chức năng cần có đơn vị lập kế hoạch riêng. Đó là do đa luồng xuất phát, thay vì một luồng, chúng ta có nhiều luồng thực thi. Một số môi trường (MPI) và ngôn ngữ có thể tạo luồng một cách ngầm định, nhưng thông thường chúng ta phải thực hiện việc này một cách rõ ràng bằng cách sử dụng `pthread_create` trong C, các lớp mô-đun `threading` trong Python hoặc một câu lệnh `go` đơn giản trong Go. Với một số biện pháp phòng ngừa, chúng ta có thể làm cho cùng một mã chạy gần như song song:


 def fetch_coordinates(poi, results, idx) -> None: … results[idx] = poi def main(): me = get_my_location() points = find_pois(me, 500) results = [None] * len(points) # Reserve space for each result threads = [] for i, point in enumerate(find_pois(me, 500)): # i - index for result thr = threading.Thread(target=fetch_coordinates, args=(poi, results, i)) thr.start() threads.append(thr) for thr in threads: thr.wait() for point, result in zip(points, results): sys.stdout.write(f“Name: {poi} is at x={loc.x} y={loc.y}”)


Chúng tôi đã đạt được mục tiêu về hiệu suất: chương trình của chúng tôi có thể chạy trên nhiều CPU và mở rộng quy mô khi số lượng lõi tăng lên và hoàn thành nhanh hơn. Câu hỏi kỹ thuật tiếp theo chúng ta phải hỏi: với chi phí nào?

Chúng tôi cố tình từ bỏ việc thực hiện nối tiếp và có thể dự đoán được. Có không có sự phản đối giữa hàm + thời điểm và dữ liệu. Tại mỗi thời điểm luôn có một ánh xạ duy nhất giữa hàm đang chạy và dữ liệu của nó:


Nhiều chức năng hiện hoạt động đồng thời với dữ liệu:


Hậu quả tiếp theo là lần này chức năng này có thể kết thúc trước chức năng khác, lần sau có thể làm theo cách khác. Chế độ thực thi mới này dẫn đến các cuộc chạy đua dữ liệu: khi các hàm đồng thời hoạt động với dữ liệu, điều đó có nghĩa là thứ tự các thao tác áp dụng cho dữ liệu không được xác định. Chúng tôi bắt đầu gặp phải các cuộc đua dữ liệu và học cách đối phó với chúng bằng cách sử dụng:

  • phần quan trọng: mutexes (và spinlocks)
  • thuật toán không khóa (dạng đơn giản nhất có trong đoạn trích trên)
  • công cụ phát hiện chủng tộc
  • vân vân


Tại thời điểm này, chúng tôi phát hiện ra ít nhất hai điều. Đầu tiên, có nhiều cách để truy cập dữ liệu. Một số dữ liệu được địa phương (ví dụ: các biến trong phạm vi hàm) và chỉ chúng ta mới có thể nhìn thấy (và truy cập nó) và do đó nó luôn ở trạng thái mà chúng ta đã để lại. Tuy nhiên, một số dữ liệu được chia sẻ hoặc xa . Nó vẫn nằm trong bộ nhớ tiến trình của chúng tôi, nhưng chúng tôi sử dụng những cách đặc biệt để truy cập nó và nó có thể không đồng bộ. Trong một số trường hợp, để làm việc với nó, chúng tôi sao chép nó vào bộ nhớ cục bộ để tránh chạy đua dữ liệu - đó là lý do == .dòng vô tính() == phổ biến ở Rust.


Khi chúng ta tiếp tục dòng lý luận này, các kỹ thuật khác như lưu trữ cục bộ theo luồng sẽ xuất hiện một cách tự nhiên. Chúng tôi vừa có được một tiện ích mới trong bộ công cụ lập trình của mình, mở rộng những gì chúng tôi có thể đạt được bằng cách xây dựng phần mềm.


Tuy nhiên, có một bất biến mà chúng ta vẫn có thể dựa vào. Khi chúng tôi tiếp cận dữ liệu được chia sẻ (từ xa) từ một chuỗi, chúng tôi luôn nhận được dữ liệu đó. Không có trường hợp nào một số đoạn bộ nhớ không có sẵn. Hệ điều hành sẽ chấm dứt tất cả những người tham gia (luồng) bằng cách hủy tiến trình nếu vùng bộ nhớ vật lý sao lưu gặp trục trặc. Điều tương tự cũng áp dụng cho chuỗi “của chúng tôi” nếu chúng tôi khóa một mutex, không thể nào chúng tôi có thể mất khóa và phải dừng công việc chúng tôi đang làm ngay lập tức. Chúng ta có thể dựa vào tính bất biến này (được thực thi bởi hệ điều hành và phần cứng hiện đại) rằng tất cả những người tham gia đều còn sống hoặc đã chết. Tất cả đều chung số phận : nếu tiến trình (OOM), hệ điều hành (lỗi hạt nhân) hoặc phần cứng gặp sự cố - tất cả các luồng của chúng ta sẽ không còn tồn tại cùng nhau nếu không có tác dụng phụ còn sót lại từ bên ngoài.


Phát minh ra một quy trình

Một điều quan trọng cần lưu ý. Chúng tôi đã thực hiện bước đầu tiên này bằng cách giới thiệu các chủ đề như thế nào? Chúng tôi chia tay, chia đôi. Thay vì có một đơn vị lập kế hoạch, chúng tôi đã giới thiệu nhiều đơn vị. Hãy tiếp tục áp dụng phương pháp không chia sẻ này và xem nó diễn ra như thế nào. Lần này chúng ta sao chép bộ nhớ ảo của tiến trình. Đó được gọi là - sinh ra một quá trình . Chúng tôi có thể chạy một phiên bản khác của chương trình của mình hoặc khởi động tiện ích hiện có khác. Đây là một cách tiếp cận tuyệt vời để:

  • sử dụng lại mã khác với ranh giới nghiêm ngặt
  • chạy mã không đáng tin cậy, cô lập nó khỏi bộ nhớ của chúng ta


Hầu như tất cả == trình duyệt hiện đại ==hoạt động theo cách này để chúng có thể chạy mã thực thi Javascript không đáng tin cậy được tải xuống từ Internet và chấm dứt mã đó một cách đáng tin cậy khi bạn đóng một tab mà không cần hạ toàn bộ ứng dụng xuống.

Đây là một chế độ thực thi khác mà chúng tôi đã phát hiện ra bằng cách từ bỏ tính bất biến số phận chungkhông chia sẻ bộ nhớ ảo cũng như tạo một bản sao. Bản sao không miễn phí:

  • HĐH cần quản lý cấu trúc dữ liệu liên quan đến bộ nhớ (để duy trì ánh xạ ảo -> vật lý)
  • Một số bit có thể đã được chia sẻ và do đó các tiến trình sẽ tiêu tốn thêm bộ nhớ



Đột phá

Tại sao dừng lại ở đây? Hãy cùng khám phá những gì khác mà chúng tôi có thể sao chép và phân phối chương trình của mình. Nhưng tại sao lại đi phân phối ngay từ đầu? Trong nhiều trường hợp, các công việc hiện tại có thể được giải quyết bằng một máy duy nhất.


Chúng ta cần phải đi phân phối để thoát khỏi số phận chung nguyên lý để phần mềm của chúng tôi dừng lại tùy thuộc vào các vấn đề không thể tránh khỏi mà các lớp bên dưới gặp phải.


Đến tên một vài:

  • Nâng cấp hệ điều hành: đôi khi chúng tôi cần khởi động lại máy

  • Lỗi phần cứng: chúng xảy ra thường xuyên hơn chúng ta mong muốn

  • Sự cố bên ngoài: mất điện và mất mạng là một chuyện.


Nếu chúng tôi sao chép một hệ điều hành - chúng tôi gọi đó là máy ảo và có thể chạy các chương trình của khách hàng trên máy vật lý và xây dựng hoạt động kinh doanh đám mây khổng lồ trên đó. Nếu chúng tôi sử dụng hai máy tính trở lên và chạy các chương trình của mình trên mỗi máy tính - chương trình của chúng tôi có thể tồn tại lâu hơn ngay cả khi xảy ra lỗi phần cứng, cung cấp dịch vụ 24/7 và đạt được lợi thế cạnh tranh. Các tập đoàn lớn từ lâu thậm chí còn đi xa hơn và giờ đây các gã khổng lồ Internet chạy các bản sao ở các trung tâm dữ liệu khác nhau và thậm chí ở các lục địa, do đó giúp chương trình có khả năng phục hồi trước bão hoặc mất điện đơn giản.


Nhưng sự độc lập này phải trả giá: những bất biến cũ không được thực thi, chúng ta phải tự mình thực hiện. Đừng lo lắng, chúng tôi không phải là những người đầu tiên. Có rất nhiều kỹ thuật, công cụ và dịch vụ có thể giúp chúng tôi.


Đồ ăn mang về

Chúng ta vừa đạt được khả năng suy luận về các hệ thống và cơ chế thực thi tương ứng của chúng. Bên trong mọi hệ thống quy mô lớn, hầu hết các bộ phận đều có tính tuần tự và không trạng thái quen thuộc, nhiều thành phần đa luồng với các loại bộ nhớ và hệ thống phân cấp được liên kết với nhau bằng sự kết hợp của một số bộ phận được phân phối thực sự:


Mục tiêu là để có thể phân biệt chúng ta đang ở đâu, những gì bất biến nắm giữ và hành động (sửa đổi/thiết kế) tương ứng. Chúng tôi nhấn mạnh lý do cơ bản, chuyển đổi “những ẩn số chưa biết” thành “những ẩn số đã biết”. Đừng xem nhẹ nó, đây là một tiến bộ đáng kể.