Vượt qua `fork()` + `exec()`
(lwn.net)- 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:
execfdhoặc đường dẫn tuyệt đốifilename, 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 khaiposix_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ònexec()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() và 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ởiexec(), 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ồiexec()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()và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);
- Chữ ký có dạng
- 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
execfdhoặc đường dẫn tuyệt đốifilename, 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_argsargvlà con trỏ tới danh sách đối số truyền cho chương trìnhenvplà con trỏ tới môi trường của chương trìnhactionslà con trỏ tới mảngspawn_template_actiondùng để truyền các thay đổi về bộ mô tả tệp và xử lý tín hiệu
spawn_template_actiongồm các trườngtype,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ònfdlà 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
- Nếu cần đóng bộ mô tả tệp 4 trong tiến trình con thì
- 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);
- Chữ ký có dạng
- 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ẫufork()/exec()- Cách triển khai hiện nay che giấu
fork()và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ànhThay 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
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 chaCá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ọiexec()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
.sosẽ 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ơnfork(), lại rất chậmTô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ôngfork(): The Scalable Commutativity Rule: Designing Scalable Software for Multicore Processors https://people.csail.mit.edu/nickolai/papers/clements-sc.pdffork()rất tuyệt cho mẫu zygoteKhó 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
O_CLOEXECsao?posix_spawnsao?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ọifork()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
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
fork()không sao chép chính phần bộ nhớ, nhưng vẫn phải sao chép page tableNế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.rdbhoặc ghi lại AOF log nhị phânNgay 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.xlargedù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 tableThậ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
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 khiexec()được thực thiViệ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ỗ saufork()có thể dùng nguyên các API thông thường để thực hiện mọi kiểu cấu hìnhCho đế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
fork()/exec()có thể hữu ích trong một số trường hợp, nếu các API nhận đối sốpidfdthì có vẻ cũng khá ổn. Có thể để 0 mang nghĩa là tiến trình hiện tạiVấ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 ởexecsẽ tốt hơnVí 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ểusetuid(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ư
CreateProcessnhận vô số tham số không hẳn là API không gian người dùng xuất sắcVí 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à đúngfork()+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ốpidtườ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
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 minhKhi đó 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
Nếu tiến trình khởi đầu ở trạng thái được gắn
ptracevà 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()vàexec()ở bên trong như cách triển khai hiện tại”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 quanNhư 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 độnggittừ đầ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ạiNế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
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ốtlibgit2. Có thể tưởng tượng cách giao tiếp với mộtgitdnà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ìnhLý do khó thay thế
exec/forklà 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ìnhseccomp, điều chỉnh quyền hạnNhư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ư
spawncó 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ínhNhư 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
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ìforkchỉ đơn giản là bí danh củaclone()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ề legacyNếu
forkvàexeccó 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