2 điểm bởi GN⁺ 3 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • spawn templates là một đề xuất tạo tiến trình cho nhân Linux nhằm để nhân lưu đệm thông tin tệp thực thi trong các ứng dụng chạy lặp đi lặp lại cùng một tệp thực thi, từ đó tăng tốc các lần khởi động tiến trình sau
  • fork() phải sao chép toàn bộ trạng thái tiến trình, gồm cả bộ nhớ, cho tiến trình con, trong khi exec() ngay sau đó thường sẽ loại bỏ phần bộ nhớ ấy, tạo ra sự kém hiệu quả của mẫu hiện tại
  • spawn_template_create() chỉ định tệp thực thi bằng một trong hai cách: execfd hoặc đường dẫn tuyệt đối filename, rồi trả về bộ mô tả tệp của mẫu; nhân sẽ mở tệp đó và lưu đệm thông tin cần thiết cho việc thực thi nhanh
  • spawn_template_spawn() hoạt động theo cách gần với đường đi fork()/exec() thông thường, vẫn giữ nguyên các kiểm tra áp dụng khi thực thi tệp mới, và benchmark trong thư giới thiệu cho thấy cải thiện khoảng 2% {p:2}
  • Tạo tiến trình rỗng dựa trên pidfd cùng cấu hình bằng pidfd_config() được đánh giá là cách tiếp cận tốt hơn, với mục tiêu hỗ trợ triển khai posix_spawn() trong không gian người dùng

Giới hạn của mô hình tạo tiến trình Unix

  • Từ buổi đầu của Unix, fork() là lời gọi hệ thống cốt lõi theo định hướng tiến trình để tạo tiến trình con như một bản sao của tiến trình cha, còn exec() chạy một chương trình mới thay vào vị trí của tiến trình hiện tại
  • Trong nhân Linux, cùng chức năng cốt lõi đó thường được biết đến rõ hơn dưới tên clone()execve()
  • Mô hình tạo tiến trình này vừa có nét thanh lịch vừa có nhược điểm; đề xuất spawn templates của Li Chen sẽ không được chấp nhận vào nhân Linux ở dạng hiện tại, nhưng có thể dẫn tới các primitive tạo tiến trình mới trong tương lai
  • fork() là một lời gọi hệ thống tương đối tốn kém vì phải sao chép toàn bộ trạng thái tiến trình, gồm cả bộ nhớ, để tạo tiến trình con
  • Trong nhiều năm đã có nhiều tối ưu hóa, nhưng về bản chất fork() vẫn là một thao tác đắt đỏ
  • Nhiều trường hợp lời gọi fork() được theo ngay bởi exec(), và exec() sẽ loại bỏ toàn bộ phần bộ nhớ đã sao chép cho tiến trình con
  • Đã có các nỗ lực tối ưu như vfork(), nhưng mẫu fork() rồi exec() vẫn đắt hơn mức có thể đạt được

Spawn templates

  • Bộ bản vá của Li Chen tập trung vào các ứng dụng chạy lặp đi lặp lại cùng một tệp thực thi để tối ưu mẫu fork()exec()
  • Một ví dụ là chương trình phải chạy Git lặp đi lặp lại để lấy thông tin về nội dung kho lưu trữ
  • Trong những trường hợp như vậy, chương trình có thể tạo một mẫu để dàn trải chi phí thiết lập qua nhiều lần chạy, rồi tăng tốc các lần gọi bằng mẫu đó
  • Việc tạo mẫu dùng lời gọi hệ thống spawn_template_create()
    • Chữ ký có dạng int spawn_template_create(struct spawn_template_create_args *args, size_t args_size);
  • Lời gọi này trả về một bộ mô tả tệp đại diện cho mẫu tệp thực thi
  • Tệp thực thi phải được chỉ định bằng bộ mô tả tệp execfd hoặc đường dẫn tuyệt đối filename, và không thể dùng cả hai cùng lúc
  • Nhân sẽ mở tệp được chỉ định và lưu đệm nhiều loại thông tin cần thiết để thực thi tệp đó nhanh hơn về sau
  • Mỗi lần thực thi có thể có đối số, môi trường, thay đổi bộ mô tả tệp và thay đổi xử lý tín hiệu khác nhau
  • Thông tin thực thi cụ thể được đặt trong cấu trúc spawn_template_spawn_args
    • argv là con trỏ tới danh sách đối số truyền cho chương trình
    • envp là con trỏ tới môi trường của chương trình
    • actions là con trỏ tới mảng spawn_template_action dùng để truyền các thay đổi về bộ mô tả tệp và xử lý tín hiệu
    Quảng cáo
  • spawn_template_action gồm các trường type, flags, fd, newfd, arg
    • Nếu cần đóng bộ mô tả tệp 4 trong tiến trình con thì type được đặt là SPAWN_TEMPLATE_ACTION_CLOSE, còn fd là 4
    • Các hành động khác hỗ trợ nhân bản bộ mô tả tệp, mở tệp, đổi thư mục làm việc và thay đổi xử lý tín hiệu
  • Sau khi điền xong thông tin thực thi, tiến trình mới được chạy bằng spawn_template_spawn()
    • Chữ ký có dạng int spawn_template_spawn(int template_fd, struct spawn_template_spawn_args *args, int args_size);
  • Cách hoạt động nội bộ gần với đường đi fork()/exec() thông thường
  • Mọi kiểm tra thông thường áp dụng khi thực thi tệp mới đều được giữ nguyên
  • Thông tin được lưu đệm trong mẫu giúp tăng tốc toàn bộ luồng tạo tiến trình
  • Kết quả benchmark trong thư giới thiệu cho thấy cải thiện khoảng 2%, con số có thể tạo khác biệt với những ứng dụng đúng mẫu dự kiến {p:2}

Hướng tới posix_spawn()

  • Mateusz Guzik đánh giá rằng “toàn bộ thành ngữ fork + exec thật khủng khiếp và nên bị loại bỏ”
  • Điểm lạ của bộ bản vá là nó giữ nguyên phần fork(), trong khi phần lớn chi phí được cho là nằm ở đó
  • Việc tối ưu nên loại bỏ khâu sao chép tiến trình hiện tại và tạo ra một “tiến trình sạch” (pristine process)
  • Christian Brauner cho rằng ý tưởng về một builder API cho exec “không hẳn là quá kỳ lạ”
  • Tuy vậy, ông thiên về cách tiếp cận xây API mới trên lớp trừu tượng pidfd hiện có
  • Chưa có chi tiết cụ thể, nhưng việc thêm vào pidfd_open() một tùy chọn để tạo tiến trình rỗng được xem là hướng đi đúng
  • Sau đó có thể gọi lời gọi hệ thống pidfd_config() nhiều lần để áp dụng cho tiến trình mới các cấu hình mong muốn như môi trường hay image cần thực thi
  • pidfd_config() đóng vai trò tương tự fsconfig()
  • Mục tiêu quan trọng của giao diện mới là hỗ trợ triển khai posix_spawn() trong không gian người dùng
  • posix_spawn() là lựa chọn phù hợp để thay thế mẫu fork()/exec()
  • Cách triển khai hiện nay che giấu fork()exec() bên trong, còn cách triển khai native sẽ khác với cấu trúc đó
  • Li Chen đồng ý rằng API được Brauner phác họa ở mức rộng trông có vẻ tốt hơn, và dự định sẽ tiếp tục công việc theo hướng đó
  • Spawn templates sẽ không vào nhân Linux, nhưng nếu công việc tiếp theo đơm hoa kết trái thì Linux có thể sẽ có một triển khai posix_spawn() đúng nghĩa

1 bình luận

 
Ý kiến trên Hacker News
  • Có một bài báo liên quan là A fork() in the road: https://www.microsoft.com/en-us/research/wp-content/uploads/...
    Phần tóm tắt lập luận rằng, trái với quan niệm phổ biến cho rằng tổ hợp fork()+exec() là một thiết kế đầy cảm hứng trong Unix, nó là một thủ thuật thông minh cho máy móc và chương trình của thập niên 1970, nhưng giờ là một sự trừu tượng tệ đối với lập trình viên hiện đại và còn hạn chế cả việc triển khai hệ điều hành
    Thay vì giữ nó như một nguyên thủy hạng nhất của hệ điều hành, nên dạy nó như một di vật lịch sử và không để nó trở thành cách tạo tiến trình đầu tiên mà sinh viên được học

    • Lý do fork()+exec() trở thành như vậy là để có thể chạy các chương trình quá lớn để không thể cùng nằm trong bộ nhớ với chương trình cha
      Cách triển khai ban đầu là khi gọi fork(), chương trình đang fork sẽ bị swap ra đĩa, rồi trước khi quyền điều khiển quay lại, mục trong bảng tiến trình được sao chép và điều chỉnh để tạo ra một tiến trình ở trong bộ nhớ và một tiến trình đã bị swap ra; phía còn trong bộ nhớ sẽ nhận quyền điều khiển để gọi exec()
      Cách này cho phép chạy các chương trình lớn ngay cả trên những máy PDP-11 nhỏ, và là điều cần thiết trong thời kỳ bộ nhớ cực kỳ đắt đỏ
      QNX thì thú vị ở chỗ việc nạp chương trình không nằm trong hệ điều hành mà nằm trong thư viện. Nó đọc header của tệp thực thi, cấp phát bộ nhớ, nạp chương trình và chuẩn bị cho việc chạy, rồi liên kết vào .so sẽ khởi động nó; trình nạp chương trình chạy trong không gian người dùng không đặc quyền. Có lẽ cách này gần với hướng đúng hơn
    • Điều thú vị là việc tạo tiến trình trên Windows, hệ điều hành “lớn” được dùng rộng rãi nhất không dùng fork(), lại rất chậm
      Tôi đồng ý rằng nên có một nguyên thủy khác thay cho fork(), nhưng không chắc hiệu năng có phải là lập luận mạnh nhất hay không
    • Bài báo này cũng hay, và tài liệu tham khảo [29] cũng đặc biệt tốt khi bàn về những điểm tinh tế của giao diện có thể mở rộng, bao gồm cả fork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdf
    • Thảo luận khi đó ở đây: https://news.ycombinator.com/item?id=19621799 - A fork() in the road (2019-04-10, 178 bình luận)
    • fork() rất tuyệt cho mẫu zygote
      Khó mà nghĩ ra được một tối ưu hóa vừa hiệu quả vừa thanh lịch đến thế
  • Gần đây tôi gặp một lỗi khó hiểu phát sinh do phải đóng thêm nhiều file descriptor trong tiến trình đã được fork
    Theo kinh nghiệm của tôi, trường hợp “tôi muốn một bản sao của tiến trình hiện tại” ít gặp hơn rất nhiều so với “tôi muốn một tiến trình hoàn toàn mới”, nhưng lại không có cách nào biểu đạt trực tiếp điều sau, chỉ có thể xấp xỉ bằng cách sao chép rồi sửa lại sau đó, điều này có cảm giác rất kỳ quặc

    • Thường thì bạn sẽ muốn giao tiếp với tiến trình đó, nên cần thiết lập một số thứ như file descriptor và truyền thông tin của tiến trình cha chẳng hạn
    • Chẳng phải chuyện đó được giải quyết bằng O_CLOEXEC sao?
    • Nếu nói “cách biểu đạt trực tiếp điều sau” thì chẳng phải đó chính là mục đích của posix_spawn sao?
    • Chính xác thì “một tiến trình hoàn toàn mới” nghĩa là gì?
  • Việc nói rằng “fork() là một system call tương đối đắt đỏ, và phải sao chép toàn bộ trạng thái tiến trình, bao gồm cả bộ nhớ, cho tiến trình con. Trong nhiều năm đã có rất nhiều tối ưu hóa, nhưng về bản chất đây vẫn là một thao tác tốn kém. Tệ hơn nữa là sau lời gọi fork() thường ngay lập tức là exec(), nghĩa là toàn bộ bộ nhớ đã cẩn thận sao chép cho tiến trình con lại bị vứt bỏ” mà không nhắc đến copy-on-write thì khá lạ
    Đó là tối ưu hóa giúp tránh phải sao chép toàn bộ bộ nhớ thực tế, nhưng lại bị bỏ qua

    • Bài viết có ngầm tính đến điều đó, nhưng ở đây việc sao chép trạng thái tiến trình là nói đến các cấu trúc quản lý bộ nhớ. Chủ yếu là page table và VMA
      Dù phần bộ nhớ mà các trang thực tế trỏ tới có thể được chia sẻ, vẫn phải cấp phát các trang mới để chứa bản sao của những cấu trúc này. Và bản thân việc duyệt qua toàn bộ các cấu trúc đó để sao chép vẫn rất tốn kém
    • Redis là một kiểu tiến trình mà chi phí này đặc biệt quan trọng. fork() không sao chép chính phần bộ nhớ, nhưng vẫn phải sao chép page table
      Nếu là một tiến trình đang giữ vài chục GB RAM thì fork() có thể mất nhiều thời gian, và điều này xảy ra mỗi lần Redis dump tệp .rdb hoặc ghi lại AOF log nhị phân
      Ngay từ năm 2012 đã có một bài viết cho thấy chi phí cao của thao tác này: https://redis.io/blog/testing-fork-time-on-awsxen-infrastruc...
      Trên m2.xlarge dùng khoảng 25GB RAM, fork() mất 5,67 giây. Xét rằng client Redis thường chỉ chịu độ trễ cỡ vài mili giây cho hầu hết tác vụ, đây là một khoảng dừng rất dài. Và đó mới chỉ là thời gian sao chép page table
      Thật đáng ngạc nhiên khi không nhắc đến huge page; ở đây nó có vẻ là một yếu tố cốt lõi cần cân nhắc. Sau 14 năm phần cứng có lẽ đã nhanh hơn, nhưng các instance Redis cũng có khả năng dùng nhiều RAM hơn, nên sẽ khá thú vị nếu benchmark này được chạy lại
    • Với nhóm độc giả mục tiêu của kiểu bài báo này, có lẽ copy-on-write là kiến thức nền nên đã bị lược bỏ
    • Dù có copy-on-write, fork() vẫn phải trả chi phí thiết lập cho nó. Nếu tiến trình cha có nhiều thread bận rộn, ví dụ như trong Java, có thể phát sinh rất nhiều copy-on-write không cần thiết trước khi exec() được thực thi
    • Bài gốc dùng từ “trạng thái”. Ngay cả với copy-on-write thì chỉ là không sao chép nội dung, còn chi phí tỷ lệ với số lượng mục trong page table vẫn còn đó
      Việc fork một chương trình có kích thước bộ nhớ ảo lớn bị chậm là một vấn đề đã được biết đến rộng rãi
  • Sự thanh lịch của mô hình fork()+exec() nằm ở chỗ sau fork() có thể dùng nguyên các API thông thường để thực hiện mọi kiểu cấu hình
    Cho đến nay, các phương án thay thế kiểu gọi gộp mà tôi thấy về cơ bản đều có vẻ nghèo nàn, vì phải thêm mọi tùy chọn cấu hình vào tham số lời gọi, đồng thời còn phải thiết kế sao cho sau này có thể mở rộng mà không biến thành mớ hỗn độn

    • Tôi không hoàn toàn đồng ý, nhưng thấy nó có ích. Dù fork()/exec() có thể hữu ích trong một số trường hợp, nếu các API nhận đối số pidfd thì có vẻ cũng khá ổn. Có thể để 0 mang nghĩa là tiến trình hiện tại
      Vấn đề có lẽ là các binary setuid/setgid, nhưng trong trường hợp này có thể xử lý đặc biệt ở exec sẽ tốt hơn
      Ví dụ có thể tạo một tiến trình đang dừng bằng pidfd_t ps = spawn();, rồi cấu hình kiểu setuid(ps, 33);, capset(ps, ...);, socket(ps, ...);, mmap(ps, ...);, process_vm_writev(ps, ...);, exec(ps, ...);, signal(ps, SIGCONT);
      Đây cũng là lời phê bình rằng API system call thông thường không cân nhắc đầy đủ câu hỏi “nếu tôi muốn làm việc này với một tiến trình khác mà tôi có quyền truy cập thì sao?”. Làm vậy cũng giúp phần nào về độ an toàn luồng trong fork()
      Tuy vậy, tôi đồng ý rằng kiểu như CreateProcess nhận vô số tham số không hẳn là API không gian người dùng xuất sắc
    • Tôi hoàn toàn phản đối. Sai lầm lớn của mô hình kiểu UNIX là khi tạo tiến trình thì quá nhiều trạng thái được giữ lại
      Ví dụ có những API làm cho một đối tượng trở thành file descriptor số 4, rồi có thể chạy chương trình để chương trình đó tìm đối tượng ấy ở descriptor số 4. Điều này rất kỳ quặc
      Windows, dù có vô số khuyết điểm, không dùng fork()+exec() mà chủ yếu cung cấp các tùy chọn về cách tạo tiến trình. Nó không thanh lịch, nhưng hướng đi là đúng
    • Gọi đó là thanh lịch chỉ là phụ thuộc lộ trình của lịch sử fork()+exec()
      Trong một thế giới khác không có fork()+exec(), nhiều “API thông thường” kiểu đó hẳn đã có đối số pid tường minh để thay đổi cấu hình của tiến trình khác. Fuchsia đại khái làm theo cách đó
      Thế giới ấy có nhiều ưu điểm. Rõ ràng nhất là không cần phải thần kỳ dựng thêm một cơ chế IPC riêng chỉ để báo lỗi cấu hình, và việc có một tiến trình quản lý để điều chỉnh thuộc tính của tiến trình con cũng khá hữu ích. Các debugger chắc sẽ đặc biệt thích điều này
    • Cách đúng để loại bỏ fork() là để các API thông thường dùng để thay đổi trạng thái tiến trình nhận handle tiến trình tường minh
      Khi đó có thể dùng cùng một API để cấu hình tiến trình trống, và còn kết hợp được với các cách khác như IPC hay gỡ lỗi
    • Thứ tự nên là spawn, configure, exec
      Nếu tiến trình khởi đầu ở trạng thái được gắn ptrace và không có luồng nào, thì có thể ép thực hiện system call trong giai đoạn cấu hình. Linux thậm chí còn không có khái niệm “tiến trình không có luồng”, nên có lẽ sẽ cần một luồng giả
  • Hiểu lầm rằng fork() là rẻ phổ biến đến mức kỳ lạ, nhưng nó có độ phức tạp O(N) theo kích thước tiến trình và từ trước đến nay vẫn vậy
    Đúng, nó là copy-on-write. Nhưng giữa kích thước tiến trình và số mục bảng trang cần để biểu diễn nó tồn tại quan hệ tuyến tính

  • Việc bản vá của Chen bị từ chối không có gì đáng ngạc nhiên. Trường hợp sử dụng quá đặc thù nên ít đáng để hỗ trợ
    Từ góc nhìn của nhà phát triển shell, tôi đồng ý với kết luận rằng “các nhà phát triển nhiều khả năng sẽ hoan nghênh một triển khai native không che giấu fork()exec() ở bên trong như cách triển khai hiện tại”

    • Có vẻ người ta quan tâm đến chính khái niệm đó chứ không phải một triển khai cụ thể
  • Ngay từ lần đầu học về fork(), nó đã có vẻ khủng khiếp về mặt khái niệm. Nếu muốn làm một việc duy nhất là khởi động tiến trình, thì không nên phải đi qua một câu thần chú khó hiểu là fork tiến trình hiện tại, vốn là một việc khác và không liên quan
    Như ví dụ trong bài, tôi tò mò cách tốt nhất để xử lý tình huống một tiến trình khởi chạy nhiều tiến trình con git. Việc cứ lặp đi lặp lại khởi động git từ đầu trong khi tác vụ cha chạy lâu dường như không hợp lý; vậy có trừu tượng chi phí thấp nào cho ra cùng kết quả không?

    • fork() đơn giản về mặt khái niệm. Nếu không kéo thêm tầng khác vào, bạn khởi động tiến trình từ chính bản thân mình, thứ duy nhất chắc chắn là đang tồn tại
      Nếu không thì cần nhiều bước để tạo tiến trình, lấp nó bằng thứ gì đó để chạy, rồi sắp cho nó thực thi. Hoặc phải trộn vĩnh viễn nó với các tầng khác như hệ thống tập tin, object loader, linker kiểu Win32
    • Là người xuất phát từ Windows, mô hình fork()+exec() hoàn toàn vô nghĩa với tôi. Giờ thì tôi biết nó chỉ là một dị thường lịch sử, nhưng vẫn có người giả vờ như fork()+exec() thực sự là thứ tốt
    • libgit2. Có thể tưởng tượng cách giao tiếp với một gitd nào đó qua pipe hoặc socket, nhưng tôi không hiểu vì sao đó lại là ý hay. Ngoài ra thì vẫn phải khởi chạy tiến trình
  • Lý do khó thay thế exec/fork là vì tiến trình mới thường cần được cấu hình. Chẳng hạn phải thiết lập signal handler, đóng hoặc mở file descriptor, chuyển namespace, cấu hình seccomp, điều chỉnh quyền hạn
    Nhưng các system call cho việc đó hiện chỉ áp dụng cho tiến trình hiện tại, nên cần một cách thay thế. Đề xuất của bài là tạo API mới cho việc này
    Theo tôi, một system call mới như spawn có thể tạo tiến trình trống, nạp vào đó một loader nhẹ, rồi truyền dữ liệu cấu hình tùy ý. Loader sẽ cấu hình tiến trình và exec() chương trình chính
    Như vậy có thể giữ nguyên API hiện có mà không phải fork bộ nhớ, nhưng file descriptor và những thứ khác vẫn phải được sao chép

    • May mà có ai đó đi cỗ máy thời gian, đọc bài này rồi thêm nó vào POSIX.1-2001 :)
      Nếu tôi hiểu nhầm câu đùa thì xin lỗi, nhưng posix_spawn() đã tồn tại rồi và trong glibc thì fork chỉ đơn giản là bí danh của clone()
      Dù không hoàn toàn giống hệt đề xuất ban đầu, fork()/exec() thực sự đã gần như thuộc về legacy
  • Nếu forkexec có thể thể hiện hành vi bền vững và mang tính đại số vượt ra ngoài đặc tính copy-on-write, thì chúng không chỉ hữu ích hơn mà còn thú vị hơn để sử dụng. Ví dụ, có thể dùng cho lazy evaluation

  • Đã có nhiều thảo luận về API cũ này trên Hacker News, ví dụ như https://news.ycombinator.com/item?id=31739794