Quay lại phần Tin tức dành cho nhà phát triển

Signals in prod: dangers and pitfalls

27 tháng 9, 2022Tác giảChris Down

Trong bài viết trên blog này, Chris Down - Kỹ sư Kernel tại Meta - thảo luận về những cạm bẫy khi sử dụng tín hiệu Linux trong môi trường chính thức của Linux và lý do các nhà phát triển nên tránh sử dụng tín hiệu bất cứ khi nào có thể.

Tín hiệu Linux là gì?

Tín hiệu là sự kiện mà các hệ thống Linux tạo ra để phản hồi điều kiện nào đó. Tín hiệu có thể được gửi từ kernel đến một quy trình, từ quy trình này đến quy trình khác hoặc từ một quy trình đến chính nó. Sau khi nhận tín hiệu, một quy trình có thể hành động.

Tín hiệu là phần cốt lõi của các môi trường điều hành tương tự Unix và đã tồn tại trong thời gian dài. Tín hiệu là đường dẫn cho nhiều thành phần cốt lõi của hệ điều hành - kết xuất bộ nhớ, quản lý vòng đời quy trình, v.v. Nhìn chung, tín hiệu đã hoạt động khá hiệu quả trong khoảng 50 năm qua, kể từ khi ra đời. Vì vậy, khi ai đó gợi ý rằng việc sử dụng tín hiệu để giao tiếp giữa các quy trình (IPC) tiềm ẩn mối nguy hiểm, mọi người có thể nghĩ đó là những lời nói huyên thuyên của kẻ khát khao được nổi tiếng. Tuy nhiên, bài viết này nhằm giới thiệu các trường hợp trong đó tín hiệu là nguyên nhân gây ra vấn đề sản xuất, đồng thời đề xuất một số biện pháp giảm thiểu và thay thế tiềm năng.

Tín hiệu có vẻ hấp dẫn do tính chuẩn hóa, được cung cấp rộng rãi và không yêu cầu thêm bất kỳ phần phụ thuộc nào ngoài những gì mà hệ điều hành cung cấp. Tuy nhiên, chúng ta khó có thể sử dụng tín hiệu một cách an toàn. Tín hiệu đưa ra rất nhiều giả định buộc mọi người phải cẩn thận khi xác thực để phù hợp với yêu cầu. Nếu không, chúng ta phải cố gắng đặt cấu hình chính xác. Trong thực tế, nhiều ứng dụng - ngay cả những ứng dụng được biết đến rộng rãi - không thực hiện việc này, do đó có thể xảy ra các sự cố khó gỡ lỗi trong tương lai.

Hãy cùng xem xét một sự cố gần đây xảy ra trong môi trường chính thức của Meta, củng cố thêm lập luận về cạm bẫy khi sử dụng tín hiệu. Chúng ta sẽ giới thiệu ngắn gọn về lịch sử của một số tín hiệu và cách tín hiệu đưa chúng ta đến vị trí ngày hôm nay. Sau đó, chúng ta sẽ đối chiếu với nhu cầu hiện tại và các vấn đề đang tồn tại trong môi trường chính thức.

Sự cố

Đầu tiên, hãy cùng hồi tưởng một chút. Đội ngũ LogDevice đã dọn dẹp mã cơ sở của họ, gỡ mã và các tính năng không sử dụng. Một trong những tính năng đã ngừng hoạt động là loại nhật ký ghi lại các hoạt động nhất định mà dịch vụ thực hiện. Cuối cùng, tính năng này đã trở nên dư thừa, không có người tiêu dùng nên đã bị gỡ. Bạn có thể xem thay đổi tại đây trên GitHub. Đến lúc này, mọi thứ vẫn ổn.

Một thời gian ngắn sau khi thay đổi diễn ra, phiên bản chính thức vẫn hoạt động ổn định và phục vụ lưu lượng truy cập như thường lệ. Vài tuần sau đó, một báo cáo cho thấy các nút dịch vụ đang bị mất với tốc độ đáng kinh ngạc. Sự cố này có liên quan đến bản phát hành mới. Tuy nhiên mọi người chưa xác định được rõ vấn đề là gì. Điểm khác biệt nào đã dẫn đến sự cố?

Đội ngũ trên đã thu hẹp vấn đề thành thay đổi mã mà chúng tôi đã đề cập trước đó, dẫn đến ngừng sử dụng những nhật ký này. Nhưng lý do là gì? Có vấn đề gì với mã đó? Nếu bạn chưa biết câu trả lời, hãy xem xét sự khác biệt đó và cố tìm ra vấn đề vì khó phát hiện ra ngay và đó là sai lầm mà ai cũng có thể mắc phải.

logrotate, Xác định rõ ý định

Ít nhiều thì logrotate là công cụ tiêu chuẩn để xoay vòng nhật ký khi sử dụng Linux. Công cụ này đã được sử dụng gần 30 năm qua với khái niệm đơn giản: quản lý vòng đời của nhật ký bằng cách xoay vòng và dọn dẹp chúng.

logrotate không tự gửi bất kỳ tín hiệu nào. Vì vậy, bạn sẽ không tìm thấy nhiều thông tin về tín hiệu (nếu có) trong trang chính hoặc tài liệu của logrotate. Tuy nhiên, logrotate có thể nhận các lệnh tùy ý để thực thi trước hoặc sau khi xoay vòng. Bạn có thể xem cấu hình sau để tìm hiểu ví dụ cơ bản về cấu hình logrotate mặc định trong CentOS:

/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    sharedscripts
    postrotate
        /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
    endscript
}

Mặc dù hơi khiên cưỡng, nhưng chúng ta có thể giả định rằng cấu hình này hoạt động như dự kiến. Cấu hình này cho thấy sau khi logrotate xoay vòng bất kỳ file nào được liệt kê, công cụ sẽ gửi tín hiệu SIGHUP đến PID có trong /var/run/syslogd.pid. Đây phải là PID của phiên bản syslogd đang chạy.

Hoạt động này không có vấn đề gì và phù hợp với những ứng dụng có API công khai ổn định như nhật ký hệ thống. Tuy nhiên, đối với những ứng dụng nội bộ mà việc triển khai SIGHUP là chi tiết triển khai nội bộ có thể thay đổi bất cứ lúc nào thì sao?

Lịch sử treo

Một trong những vấn đề ở đây là trừ những tín hiệu không thể nắm bắt trong không gian người dùng nên chỉ có một ý nghĩa - chẳng hạn như SIGKILLSIGSTOP - nhà phát triển và người dùng ứng dụng phải diễn giải và lập trình ý nghĩa ngữ nghĩa của tín hiệu. Trong một số trường hợp, sự khác biệt chủ yếu mang tính học thuật, ví dụ như SIGTERM được hiểu chung là "chấm dứt một cách khéo léo trong thời gian sớm nhất có thể". Tuy nhiên, với trường hợp SIGHUP, ý nghĩa lại không rõ ràng.

SIGHUP được phát minh cho phương thức nối tiếp và ban đầu được dùng để cho biết đầu kia của kết nối đã bị ngắt. Ngày nay, tất nhiên là tín hiệu SIGHUP vẫn tiếp tục được gửi cho các sự cố tương đương: trong đó một terminal giả lập hoặc ảo bị đóng (các công cụ như nohup ẩn tín hiệu đó).

Khi Unix mới ra mắt, hệ thống cần tiến hành tải lại trình nền. Thông thường, quy trình này bao gồm tối thiểu thao tác mở lại file cấu hình/nhật ký mà không cần khởi động lại và tín hiệu dường như là một cách độc lập để đạt được điều đó. Tất nhiên là không có tín hiệu nào như vậy. Tuy nhiên, vì các trình nền này không có terminal điều khiển nên không có lý do gì để nhận tín hiệu SIGHUP. Do đó, đây có vẻ là tín hiệu thuận tiện để vận chuyển mà không gây ra tác dụng phụ rõ ràng nào.

Tuy vậy, kế hoạch này có một trở ngại nhỏ. Trạng thái mặc định của tín hiệu không phải là "bỏ qua" mà tùy theo tín hiệu cụ thể. Vì vậy, các chương trình không phải đặt cấu hình SIGTERM theo cách thủ công để tắt ứng dụng. Nếu chương trình không đặt bất kỳ bộ xử lý tín hiệu nào khác, kernel chỉ đơn giản kết thúc chương trình mà không cần bất kỳ mã nào trong không gian người dùng. Thật thuận tiện!

Tuy nhiên, một điều không thuận tiện là SIGHUP cũng có hành vi mặc định là chấm dứt ngay chương trình. Hành vi này phù hợp cho trường hợp treo ban đầu, trong đó các ứng dụng này có khả năng sẽ không cần đến nữa. Tuy nhiên, hành vi trên không còn phù hợp với ý nghĩa mới này.

Tất nhiên, nếu chúng ta gỡ tất cả vị trí có thể gửi tín hiệu SIGHUP đến chương trình, mọi thứ vẫn ổn. Vấn đề là trong mã cơ sở lớn và trưởng thành, rất khó để thực hiện điều này. SIGHUP không giống lệnh gọi IPC được kiểm soát chặt chẽ để bạn có thể dễ dàng nạp mã cơ sở vào. Tín hiệu có thể đến từ bất kỳ đâu, vào bất cứ lúc nào và ít khi bị kiểm tra hoạt động (ngoài kiểm tra cơ bản nhất "bạn có phải là người dùng này hoặc có CAP_KILL không"). Điểm mấu chốt là rất khó để xác định tín hiệu đến từ đâu. Tuy nhiên, với IPC rõ ràng hơn, chúng ta biết rằng tín hiệu này không có ý nghĩa gì và nên bỏ qua.

Từ sự cố treo đến mối nguy hiểm

Đến giờ, tôi cho rằng bạn có thể đã bắt đầu đoán được chuyện gì xảy ra. Một bản phát hành LogDevice đã bắt đầu buổi chiều định mệnh có chứa thay đổi mã nêu trên. Ban đầu, mọi chuyện không có gì đáng lo ngại. Tuy nhiên, vào nửa đêm ngày hôm sau, mọi thứ bắt đầu sụp đổ một cách bí ẩn. Lý do là đoạn dưới đây trong cấu hình logrotate của máy gửi một tín hiệu SIGHUP hiện chưa được xử lý (và nghiêm trọng) đến trình nền logdevice:

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

Khi gỡ bỏ một tính năng lớn, việc thiếu dù chỉ một đoạn ngắn trong cấu hình logrotate là vấn đề phổ biến và dễ dàng xảy ra. Thật không may, chúng ta khó có thể chắc chắn rằng mọi dấu tích cuối cùng về sự tồn tại của nó đều bị xóa bỏ ngay lập tức. Ngay cả trong những trường hợp dễ xác thực hơn, việc để lại tàn dư khi dọn dẹp mã vẫn thường xảy ra. Tuy nhiên, tàn dư đó thường không để lại hậu quả phá hủy nào, nghĩa là mảnh vụn còn lại chỉ là mã chết hoặc không hoạt động.

Về mặt khái niệm, bản thân sự cố và cách giải quyết sự cố rất đơn giản: không gửi tín hiệu SIGHUP và lan truyền các hành động LogDevice khác theo thời gian (nghĩa là không chạy quá trình này vào nửa đêm hôm đó). Tuy nhiên, chúng ta cần tập trung vào không chỉ một sắc thái này của sự cố. Sự cố này phải đóng vai trò là nền tảng để ngăn việc sử dụng tín hiệu trong môi trường chính thức, ngoại trừ các trường hợp thiết yếu và cơ bản nhất.

Những mối nguy hiểm của tín hiệu

Tín hiệu phù hợp cho mục đích gì

Đầu tiên, việc sử dụng tín hiệu làm cơ chế để tác động đến những thay đổi về trạng thái quy trình của hệ điều hành hoàn toàn có cơ sở. Các tín hiệu này (ví dụ như SIGKILL) không thể cài đặt bộ xử lý tín hiệu và thực hiện chính xác những gì bạn mong đợi. Người dùng và lập trình viên hiểu rõ hành vi của SIGABRT, SIGTERM, SIGINT, SIGSEGVSIGQUIT và những tín hiệu tương tự mặc định cho kernel.

Điểm chung của những tín hiệu này là sau khi bạn nhận được, tất cả tín hiệu đó đều tiến đến trạng thái kết thúc terminal trong chính kernel. Nghĩa là sẽ không có thêm hướng dẫn về không gian người dùng nào được thực thi sau khi bạn nhận được tín hiệu SIGKILL hoặc SIGTERM mà không có bộ xử lý tín hiệu trong không gian người dùng.

Trạng thái kết thúc terminal rất quan trọng vì trạng thái này thường cho biết bạn đang giảm độ phức tạp của ngăn xếp và mã hiện được thực thi. Các trạng thái mong muốn khác thường dẫn đến độ phức tạp cao hơn trong thực tế và khó suy luận hơn do tính đồng thời và quy trình mã trở nên lộn xộn hơn.

Hành vi mặc định nguy hiểm

Bạn có thể nhận thấy rằng chúng tôi đã không đề cập đến một số tín hiệu khác cũng chấm dứt theo mặc định. Dưới đây là danh sách tất cả tín hiệu tiêu chuẩn chấm dứt theo mặc định (ngoại trừ các tín hiệu kết xuất bộ nhớ như SIGABRT hoặc SIGSEGV vì chúng đều hợp lý):

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

Thoạt nhìn, các tín hiệu này có vẻ hợp lý, nhưng dưới đây là một vài trường hợp ngoại lệ:

  • SIGHUP: Nếu tín hiệu này chỉ được dùng như dự định ban đầu, việc mặc định chấm dứt sẽ hợp lý. Với cách sử dụng kết hợp hiện tại nghĩa là "mở lại file", tín hiệu này khá nguy hiểm.
  • SIGPOLLSIGPROF: Những tín hiệu này thuộc nhóm "nên được xử lý nội bộ bằng hàm tiêu chuẩn nào đó, chứ không phải chương trình của bạn". Mặc dù có thể vô hại, nhưng hành vi chấm dứt mặc định vẫn có vẻ không lý tưởng.
  • SIGUSR1SIGUSR2: Đây là những "tín hiệu do người dùng xác định". Có vẻ như bạn có thể dùng những tín hiệu này theo cách mình muốn. Tuy nhiên, vì đây là terminal theo mặc định, nếu triển khai USR1 cho nhu cầu cụ thể nào đó và sau này không cần dùng nữa, bạn không thể xóa mã một cách an toàn. Bạn phải cân nhắc kỹ việc bỏ qua tín hiệu này một cách rõ ràng. Vấn đề này thực sự không rõ ràng, ngay cả với những lập trình viên giàu kinh nghiệm.

Vì vậy, gần 1/3 số tín hiệu terminal thuộc diện từ đáng ngờ cho đến đặc biệt nguy hiểm khi nhu cầu của chương trình thay đổi. Tệ hơn nữa, ngay cả những tín hiệu được cho là "do người dùng xác định" cũng là một thảm họa đang chực chờ xảy ra khi ai đó quên SIG_IGN nó một cách rõ ràng. Kể cả tín hiệu SIGUSR1 hoặc SIGPOLL vô hại cũng có thể gây ra sự cố.

Đây không chỉ là vấn đề mức độ quen thuộc. Cho dù bạn biết rõ cách tín hiệu hoạt động, việc viết mã đúng với tín hiệu ngay lần đầu tiên vẫn cực kỳ khó khăn vì tín hiệu luôn phức tạp hơn nhiều so với vẻ ngoài.

Quy trình mã, tính đồng thời và lầm tưởng về SA_RESTART

Các lập trình viên thường không dành cả ngày để suy nghĩ về hoạt động bên trong của tín hiệu. Nghĩa là khi tiến hành xử lý tín hiệu trong thực tế, họ thường mắc lỗi sai khó phát hiện.

Tôi thậm chí không đề cập đến các trường hợp "không đáng kể", như sự an toàn trong hàm xử lý tín hiệu. Hầu hết các trường hợp đó đều được giải quyết đơn giản bằng cách gửi sig_atomic_t hoặc sử dụng hàng rào tín hiệu nguyên tử của C++. Không, các trường hợp này có thể dễ dàng tìm kiếm và đáng nhớ như một cạm bẫy đối với bất kỳ ai sau lần đầu tiên trải qua sự cố tín hiệu. Một vấn đề khó hơn nhiều là việc suy luận về quy trình mã của các phần danh nghĩa trong một chương trình phức tạp khi nhận được tín hiệu. Việc suy luận đòi hỏi bạn phải suy nghĩ liên tục và rõ ràng về các tín hiệu ở mỗi phần trong vòng đời ứng dụng (EINTR thì sao, SA_RESTART có đủ không? Chúng ta nên chuyển sang quy trình nào nếu quy trình này kết thúc sớm? Bây giờ, tôi có một chương trình đồng thời, điều đó có ý nghĩa gì?) hoặc thiết lập sigprocmask hay pthread_setmask cho phần nào đó trong vòng đời ứng dụng của bạn và ước rằng quy trình mã không bao giờ thay đổi (điều này chắc chắn khó xảy ra trong môi trường phát triển với tốc độ nhanh). signalfd hoặc chạy sigwaitinfo trong một chuỗi riêng có thể giúp ích phần nào, nhưng cả hai đều có đủ các trường hợp ngoại lệ và mối quan ngại về khả năng sử dụng nên khó được đề xuất.

Chúng tôi muốn tin rằng hầu hết các lập trình viên có kinh nghiệm hiện nay đều hiểu rằng ngay cả một ví dụ điển hình về việc viết đúng mã an toàn cho luồng cũng rất khó. Nếu bạn cho rằng viết đúng mã an toàn cho luồng khó thì việc viết đúng mã cho tín hiệu còn khó hơn nhiều. Bộ xử lý tín hiệu chỉ được dựa vào mã không khóa nghiêm ngặt có cấu trúc dữ liệu nguyên tử, vì quy trình thực thi chính bị đình chỉ và chúng ta không biết quy trình này đang giữ khóa gì, cũng như quy trình thực thi chính có thể đang thực hiện các thao tác phi nguyên tử. Tín hiệu cũng phải vào lại được hoàn toàn. Nghĩa là chúng phải có khả năng lồng vào nhau vì bộ xử lý tín hiệu có thể chồng chéo nếu một tín hiệu được gửi nhiều lần (hoặc ngay cả khi xử lý một tín hiệu với SA_NODEFER). Đó là một trong những lý do bạn không thể sử dụng các hàm như printf hoặc malloc trong bộ xử lý tín hiệu vì hàm này dựa vào mutex toàn cục để đồng bộ. Nếu bạn đang giữ khóa đó khi nhận được tín hiệu rồi gọi một hàm yêu cầu lại khóa đó, ứng dụng của bạn sẽ bị đình trệ. Thực sự rất khó để suy luận về điều này. Đó là lý do nhiều người chỉ đơn giản viết mã như dưới đây khi xử lý tín hiệu:

static volatile sig_atomic_t received_sighup; 

static void sighup(int sig __attribute__((unused))) { received_sighup = 1; }

static int configure_signal_handlers(void) {
  return sigaction(
    SIGHUP,
    &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART},
    NULL);
}

int main(int argc, char *argv[]) {
  if (configure_signal_handlers()) {
       /* failed to set handlers */
  }

  /* usual program flow */

  if (received_sighup) {
    /* reload */
    received_sighup = 0;
  }

  /* usual program flow */
}

Vấn đề là mặc dù signalfd hoặc các nỗ lực khác khi xử lý tín hiệu không đồng bộ có thể trông khá đơn giản và mạnh mẽ, nhưng lại bỏ qua thực tế rằng điểm gián đoạn cũng quan trọng như các hành động được thực hiện sau khi nhận tín hiệu. Ví dụ: giả sử mã không gian người dùng của bạn đang tiến hành nhập/xuất hoặc thay đổi siêu dữ liệu của các đối tượng đến từ kernel (như inode hoặc FD). Trong trường hợp này, có lẽ bạn thực sự đang ở ngăn xếp không gian kernel vào thời điểm gián đoạn. Ví dụ: một luồng có thể có dạng như bên dưới khi cố đóng ký hiệu mô tả file:

# cat /proc/2965230/stack
 [<0>] schedule+0x43/0xd0
 [<0>] io_schedule+0x12/0x40
 [<0>] wait_on_page_bit+0x139/0x230
 [<0>] filemap_write_and_wait+0x5a/0x90
 [<0>] filp_close+0x32/0x70
 [<0>] __x64_sys_close+0x1e/0x50
 [<0>] do_syscall_64+0x4e/0x140
 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9

Trong đó, __x64_sys_close là biến thể x86_64 của lệnh gọi hệ thống close - sẽ đóng ký hiệu mô tả file. Tại thời điểm này của quá trình thực thi, chúng ta đang chờ cập nhật bộ nhớ dự phòng (đó là wait_on_page_bit này). Do hoạt động nhập/xuất thường chậm hơn vài bậc so với các thao tác khác nên schedule ở đây là một cách để tự nguyện gợi ý cho bộ lập lịch CPU của kernel rằng chúng ta sắp thực hiện một thao tác có độ trễ cao (như nhập xuất qua mạng hoặc ổ đĩa) và bộ lập lịch nên cân nhắc tìm một quy trình khác để lên lịch thay cho quy trình hiện tại. Nhờ tín hiệu này, chúng ta có thể báo cho kernel biết nên tiếp tục và chọn một quy trình sẽ thực sự tận dụng CPU, chứ không lãng phí thời gian vào quy trình không thể tiếp tục trừ khi nhận được phản hồi từ yếu tố nào đó có thể mất nhiều thời gian.

Hãy tưởng tượng chúng ta gửi một tín hiệu đến quy trình mà mình đang chạy. Tín hiệu đã gửi có một bộ xử lý không gian người dùng trong luồng nhận, do đó, chúng ta sẽ tiếp tục trong không gian người dùng. Một trong nhiều cách để cuộc đua này kết thúc là kernel sẽ cố thoát ra khỏi schedule, mở rộng ngăn xếp hơn nữa và cuối cùng trả về errno ESYSRESTART hoặc EINTR vào không gian người dùng để cho biết rằng đã xảy ra gián đoạn. Nhưng chúng ta tiến được bao xa? Bây giờ, trạng thái của ký hiệu mô tả file là gì?

Giờ thì chúng ta đã trở lại không gian người dùng và sẽ chạy bộ xử lý tín hiệu. Khi bộ xử lý tín hiệu thoát, chúng ta sẽ lan truyền lỗi đến trình bao bọc close của thư viện tiêu chuẩn C trong không gian người dùng, rồi đến ứng dụng. Về lý thuyết, cách này có thể giải quyết tình huống gặp phải. Chúng ta nói "về lý thuyết" bởi vì rất khó để biết phải làm gì với tín hiệu trong nhiều tình huống này và nhiều dịch vụ trong môi trường chính thức không xử lý tốt các trường hợp ngoại lệ ở đây. Điều đó có thể ổn trong một số ứng dụng không yêu cầu quá cao về tính toàn vẹn của dữ liệu. Tuy nhiên, trong các ứng dụng chính thức quan tâm đến tính nhất quán và toàn vẹn của dữ liệu, có một vấn đề quan trọng: kernel không cho biết bất kỳ cách chi tiết nào để nắm được mức độ xử lý đến đâu, những gì đã và chưa đạt được, cũng như điều chúng ta thực sự nên làm liên quan đến tình huống. Hay tệ hơn, nếu hàm close trả về mã lỗi EINTR, trạng thái của ký hiệu mô tả file sẽ là không xác định:

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

Chúc may mắn nếu bạn đang cố suy luận về cách xử lý vấn đề đó sao cho an toàn và bảo mật trong ứng dụng của mình. Nhìn chung, việc xử lý mã lỗi EINTR rất phức tạp, kể cả đối với các lệnh gọi hệ thống hoạt động tốt. Có nhiều vấn đề tế nhị dẫn đến lý do tại sao SA_RESTART là không đủ. Không phải lệnh gọi hệ thống nào cũng đều khởi động lại được. Tôi mong rằng các nhà phát triển ứng dụng của bạn hiểu và giảm thiểu các sắc thái khi nhận tín hiệu cho mọi lệnh gọi hệ thống đơn lẻ ở từng nơi gọi đang yêu cầu ngừng hoạt động. Từ man 7 signal:

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

Tương tự như vậy, việc sử dụng sigprocmask và mong đợi quy trình mã ở trạng thái tĩnh sẽ gây rắc rối cho bạn vì các nhà phát triển thường không dành cả đời để suy nghĩ về giới hạn của việc xử lý tín hiệu hay cách tạo ra hoặc duy trì mã đúng với tín hiệu. Điều tương tự cũng diễn ra khi xử lý tín hiệu trong luồng riêng bằng sigwaitinfo. Hàm này có thể dễ dàng dẫn đến việc GDB và các công cụ tương tự không thể gỡ lỗi quy trình. Việc xử lý lỗi hoặc các quy trình mã sai khó phát hiện có thể dẫn đến lỗi, sự cố, tình trạng khó gỡ lỗi, đình trệ và nhiều vấn đề khác. Tình trạng này sẽ khiến bạn phải phụ thuộc vào công cụ quản lý sự cố ưu tiên của mình.

Độ phức tạp cao trong môi trường đa luồng

Nếu bạn cho rằng tất cả những vấn đề về tính đồng thời, khả năng vào lại và tính nguyên tử này đã đủ tệ thì việc kết hợp thêm yếu tố đa luồng sẽ khiến mọi thứ trở nên phức tạp hơn nữa. Điều này đặc biệt quan trọng khi xem xét thực tế là nhiều ứng dụng phức tạp chạy ngầm các luồng riêng như một phần của jemalloc, GLib hoặc tương tự. Một số thư viện này thậm chí còn tự cài đặt bộ xử lý tín hiệu, gây ra tình huống rắc rối khác.

Nhìn chung, man 7 signal giải thích vấn đề như sau:

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

Nói ngắn gọn hơn, "đối với hầu hết các tín hiệu, kernel sẽ gửi tín hiệu đến bất kỳ luồng nào không bị chặn tín hiệu đó bằng sigprocmask". SIGSEGV, SIGILL và những tín hiệu tương tự giống như bẫy, đồng thời có tín hiệu hướng rõ ràng đến luồng vi phạm. Tuy nhiên, dù mọi người nghĩ như thế nào, hầu hết các tín hiệu đều không thể gửi rõ ràng đến một luồng đơn lẻ trong nhóm luồng, ngay cả với tgkill hoặc pthread_kill.

Nghĩa là bạn không thể chỉ thay đổi các đặc điểm xử lý tín hiệu tổng thể ngay khi có một nhóm luồng. Nếu một dịch vụ cần định kỳ chặn tín hiệu bằng sigprocmask trong luồng chính, bạn cần dùng cách nào đó để giao tiếp với các luồng khác bên ngoài về cách xử lý. Nếu không, tín hiệu có thể bị nuốt bởi một luồng khác và không bao giờ xuất hiện nữa. Tất nhiên, bạn có thể chặn tín hiệu trong các luồng con để tránh trường hợp này. Nhưng nếu luồng đó cần tự xử lý tín hiệu, ngay cả đối với những hàm nguyên thủy như waitpid, hành động này sẽ khiến mọi thứ phức tạp hơn.

Giống như mọi vấn đề khác, về mặt kỹ thuật, vấn đề này không hẳn là không vượt qua được. Tuy nhiên, người ta sẽ sơ suất bỏ qua thực tế rằng quy trình đồng bộ cần để thực hiện chính xác công việc này rất phức tạp và dễ gây ra lỗi, nhầm lẫn và tệ hơn nữa.

Thiếu định nghĩa và thông báo thành công hoặc thất bại

Tín hiệu được lan truyền không đồng bộ trong kernel. Lệnh gọi hệ thống kill trả về ngay khi tín hiệu chờ xử lý được ghi nhận cho quy trình hoặc task_struct của luồng được đề cập. Do đó, không có gì đảm bảo tín hiệu được phân phối kịp thời, ngay cả khi không bị chặn.

Ngay cả khi tín hiệu được phân phối kịp thời, không có cách nào để thông báo lại cho trình phát tín hiệu về trạng thái của yêu cầu hành động đó. Vì vậy, tín hiệu không thể gửi bất kỳ hành động có ý nghĩa nào do chúng chỉ kích hoạt và quên mà không có cơ chế thực sự để báo cáo kết quả phân phối thành công hay thất bại và các hành động tiếp theo. Như đã nêu ở trên, ngay cả những tín hiệu dường như vô hại cũng có thể nguy hiểm khi không được đặt cấu hình trong không gian người dùng.

Chắc chắn là bất kỳ ai sử dụng Linux đủ lâu đều gặp phải trường hợp muốn dừng quy trình nào đó nhưng quy trình lại không phản hồi, ngay cả với những tín hiệu được cho là luôn dừng quy trình như SIGKILL. Vấn đề là điều đó gây hiểu lầm, mục đích của tín hiệu kill(1) không phải là dừng quy trình mà là đưa một yêu cầu gửi đến kernel vào hàng đợi (không cho biết thời điểm sẽ được xử lý). Ai đó đã yêu cầu thực hiện hành động đối với yêu cầu trên.

Nhiệm vụ của lệnh gọi hệ thống kill là đánh dấu tín hiệu là đang chờ xử lý trong siêu dữ liệu tác vụ của kernel. Nhiệm vụ này thành công ngay cả khi tác vụ SIGKILL không dừng. Cụ thể là trong trường hợp của SIGKILL, kernel không thực thi thêm hướng dẫn chế độ người dùng nào, nhưng có thể chúng ta vẫn phải thực thi hướng dẫn trong chế độ kernel để hoàn tất hành động. Nếu không sẽ dẫn đến lỗi dữ liệu hoặc thất thoát tài nguyên. Vì lý do này, chúng ta vẫn thành công ngay cả khi trạng thái là D (ngủ liên tục). Bản thân lệnh kill sẽ không thất bại trừ khi bạn cung cấp tín hiệu không hợp lệ, bạn không có quyền gửi tín hiệu đó hoặc pid mà bạn yêu cầu gửi tín hiệu không tồn tại. Do vậy, không hữu ích khi lan truyền một cách tin cậy các trạng thái không phải terminal đến ứng dụng.

Kết luận

  • Tín hiệu phù hợp với trạng thái terminal được xử lý hoàn toàn trong kernel mà không có bộ xử lý không gian người dùng. Đối với các tín hiệu mà bạn thực sự muốn dừng chương trình ngay lập tức, hãy để nguyên các tín hiệu đó cho kernel xử lý. Nhờ thế, kernel cũng có thể sớm thoát khỏi công việc của mình, giải phóng tài nguyên chương trình nhanh hơn, trong khi yêu cầu IPC không gian người dùng sẽ phải đợi phần không gian người dùng bắt đầu thực thi lại.
  • Một cách để tránh gặp rắc rối khi xử lý tín hiệu là không xử lý gì cả. Tuy nhiên, đối với các ứng dụng xử lý trạng thái phải thực hiện một số trường hợp như SIGTERM. Bạn nên sử dụng API cấp cao như folly::AsyncSignalHandler, trong đó một số wart đã được làm cho trực quan hơn.

  • Tránh gửi yêu cầu đến ứng dụng bằng tín hiệu. Hãy sử dụng thông báo tự quản lý (như inotify) hoặc RPC không gian người dùng với phần riêng trong vòng đời ứng dụng để xử lý, chứ không làm gián đoạn ứng dụng.
  • Nếu có thể, hãy giới hạn phạm vi tín hiệu ở một mục con của chương trình hoặc luồng bằng lệnh sigprocmask nhằm giảm lượng mã cần phải thường xuyên xem xét kỹ lưỡng về mức độ đúng với tín hiệu. Lưu ý rằng nếu các quy trình mã hoặc chiến lược phân luồng thay đổi, phương thức ẩn có thể không đem lại tác dụng như mong muốn.
  • Khi khởi động trình nền, việc che các tín hiệu terminal được hiểu theo cách không thống nhất và có thể được dùng lại vào thời điểm nào đó trong chương trình của bạn để tránh quay lại hành vi mặc định của kernel. Dưới đây là đề xuất của tôi:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Hành vi của tín hiệu cực kỳ phức tạp để suy luận ngay cả trong các chương trình được thiết kế tốt. Việc sử dụng tín hiệu gây ra rủi ro không đáng có trong những ứng dụng có sẵn giải pháp thay thế khác. Nói chung, không nên sử dụng tín hiệu để giao tiếp với phần không gian người dùng trong chương trình của bạn. Thay vào đó, hãy để chương trình tự xử lý các sự kiện một cách minh bạch (ví dụ: bằng inotify) hoặc sử dụng phương thức giao tiếp trong không gian người dùng có thể báo cáo lại lỗi cho trình phát, có thể liệt kê và chứng minh được tại thời điểm soạn như Thrift, gRPC hoặc tương tự.

Tôi hy vọng qua bài viết này, bạn đã nhận thấy rằng tín hiệu dù trông có vẻ đơn giản nhưng thực tế lại khá nguy hiểm. Do tính đơn giản, tín hiệu đã được dùng như một API cho phần mềm không gian người dùng, tạo ra loạt quyết định thiết kế ngầm không phù hợp với hầu hết trường hợp sử dụng chính thức trong kỷ nguyên hiện đại.

Tôi cần nói rõ rằng: có những trường hợp hợp lệ để sử dụng tín hiệu. Tín hiệu phù hợp cho hoạt động giao tiếp cơ bản với kernel về trạng thái quy trình mong muốn khi không có thành phần không gian người dùng, chẳng hạn như cần phải dừng một quy trình. Tuy nhiên, rất khó để viết mã đúng với tín hiệu ngay lần đầu khi các tín hiệu dự kiến sẽ bị mắc kẹt trong không gian người dùng.

Tín hiệu có vẻ hấp dẫn do tính chuẩn hóa, được cung cấp rộng rãi và ít phần phụ thuộc, nhưng kèm theo nhiều cạm bẫy. Những cạm bẫy này sẽ khiến bạn phải đau đầu khi dự án phát triển. Tôi hy vọng bài viết này đã cung cấp cho bạn một số biện pháp giảm thiểu và chiến lược thay thế để đạt được mục tiêu của mình, nhưng theo cách an toàn hơn, ít phức tạp hơn và trực quan hơn.

Để tìm hiểu thêm về Meta Open Source, hãy truy cập vào trang web nguồn mở của chúng tôi, đăng ký theo dõi kênh YouTube của chúng tôi hoặc theo dõi chúng tôi trên Twitter, FacebookLinkedIn.