Ứng dụng semaphore trong cấp phát tài nguyên

Ngoài spinlock và mutex lock, ta cũng có thể áp dụng kỹ thuật semaphore để bảo vệ dữ liệu trong critical resource. Không chỉ là một kỹ thuật đồng bộ tài nguyên, semaphore cũng được biết đến là một kỹ thuật đồng bộ hoạt động.

Do phạm vi của khóa học, bài học này chỉ trình bày về semaphore với tư cách là một kỹ thuật đồng bộ tài nguyên:

  • Giới thiệu semaphore là gì, có cấu tạo như thế nào, hoạt động ra sao, bảo vệ critical resource như thế nào?
  • Sử dụng kỹ thuật semaphore trong lập trình device driver như thế nào?
  • Cần chú ý những gì khi sử dụng kỹ thuật semaphore?

Giới thiệu về semaphore

Semaphore là một cấu trúc dữ liệu, được dùng để đồng bộ tài nguyên và đồng bộ hoạt động.

Khi được dùng với mục đích đồng bộ tài nguyên, semaphore tương tự như một bộ các chìa khóa dự phòng. Nếu một thread lấy được một chiếc chìa khóa, thread đó được phép truy cập vào tài nguyên. Nhưng nếu không còn chiếc chìa khóa nào, thread đó phải đợi cho tới khi một thread khác trả lại chìa khóa dự phòng. Nhờ vậy, race condition sẽ bị ngăn chặn.

Ứng dụng semaphore trong cấp phát tài nguyên

Hình 1. Sử dụng semaphore để đồng bộ tài nguyên

Semaphore có cấu tạo như thế nào?

Semaphore gồm 2 thành phần chính: biến count và hàng đợi wait_listLinux kernel sử dụng cấu trúc semaphore để biểu diễn một semaphore.

struct semaphore { /* * Do cấu trúc semaphore cũng bị nhiều thread truy cập đồng thời, * nên semaphore cũng được xem là một critical resource. Biến @lock là * một spinlock bảo vệ @count và @wait_list trong cấu trúc semaphore. */ raw_spinlock_t lock; /* * Biến count vừa thể hiện trạng thái của semaphore, vừa thể hiện * trạng thái của critical resource. * > 0: semaphore đang ở trạng thái AVAILABLE, * còn critical resource đang ở trạng thái READY. * count cũng thể hiện còn bao nhiêu thread nữa được phép * sử dụng critical resource. * = 0: semaphore đang ở trạng thái UNAVAILABLE, * còn critical resource đang ở trạng thái BUSY. */ unsigned int count; // wait_list là danh sách các thread đang chờ đợi để có được semaphore struct list_head wait_list; };

Căn cứ vào giá trị của biến count, semaphore được chia làm 2 loại là counting semaphorebinary semaphore.

  • Nếu giá trị cực đại của biến count lớn hơn 1, thì semaphore được gọi là counting semaphore. Giá trị cực đại của biến count thể hiện số lượng thread tối đa được phép sử dụng critical resource tại cùng một thời điểm.
  • Nếu biến count chỉ có hai giá trị 0 và 1, thì semaphore được gọi là binary semaphore. Binary semaphore có một số nét tương đồng với mutex lock.

Semaphore hoạt động ra sao?

Ứng dụng semaphore trong cấp phát tài nguyên

Hình 2. Sơ đồ biểu diễn các trạng thái hoạt của một semaphore

Khi count đang lớn hơn 0, tức là semaphore đang ở trạng thái AVAILABLE, nếu một thread gọi hàm down, thì biến count bị giảm đi 1 đơn vị (nếu hiệu bằng 0 thì semaphore chuyển sang trạng thái UNAVAILABLE). Sau đó, CPU bắt đầu thực thi critical section của thread (nói theo ngôn ngữ của CPU), hay thread bắt đầu sử dụng critical resource (nói theo ngôn ngữ của Linux kernel).

Khi count đang bằng 0, tức là semaphore đang ở trạng thái UNAVAILABLE, nếu một thread gọi hàm down, thì CPU tạm dừng thực thi thread này rồi chuyển sang thực thi thread khác (nói theo ngôn ngữ của CPU). Hay nói theo ngôn ngữ của Linux kernel, thread đó được thêm vào hàng đợi wait_list và đi ngủ, sau đó Linux kernel sẽ lập lịch cho thread khác. Do đó, ta nói rằng, semaphore áp dụng cơ chế sleep-waiting.

Khi wait_list vẫn còn ít nhất một thread đang phải đợi, nếu một thread A gọi hàm up, thì CPU sẽ chuyển sang thực thi thread B nằm ở vị trí đầu tiên trong hàng đợi wait_list (nói theo ngôn ngữ của CPU). Hay nói theo ngôn ngữ của Linux kernel, Linux kernel đánh thức thread B dậy, sau đó thread B bắt đầu sử dụng critical resource.

Khi wait_list không còn thread nào chờ đợi, nếu một thread gọi hàm up, thì biến count được tăng thêm 1 đơn vị, tức là semaphore chuyển sang trạng thái AVAILABLE.

Semaphore bảo vệ critical resource như thế nào?

Vì hoạt động của binary semaphore tương tự như mutex lock, nên loại semaphore này thường được sử dụng để đồng bộ dữ liệu, phòng tránh race condition. Trong khi lập trình device driver, ta đặt hàm down và up lần lượt vào trước và sau critical section của mỗi thread.

Giả sử, hệ thống có kernel thread A và B được thực thi riêng biệt trên 2 lõi CPU0 và CPU1. Cả 2 thread đều có nhu cầu sử dụng critical resource R, và tài nguyên R được bảo vệ bằng binary semaphore S. Xét 2 trường hợp:

  • Trường hợp 1: A muốn truy cập R trong khi B đang truy cập R.
    • Trước khi thực thi các lệnh trong critical section của thread A, CPU0 sẽ thực thi hàm down và thấy rằng S đang ở trạng thái UNAVAILABLE. Khi đó, CPU0 sẽ tạm dừng thực thi thread A rồi chuyển sang thực thi một thread C nào đó.
    • Sau khi thực thi xong critical section của thread B, CPU1 thực thi tiếp hàm up để đánh thức thread A dậy và CPU0 tiếp tục thực thi thread A.
  • Trường hợp 2: cả A và B đồng thời muốn truy cập R.
    • Khi đó, cả 2 thread đồng thời thực thi hàm down. Tuy nhiên, do semaphore được bảo vệ bằng một spinlock, nên chỉ có một trong hai thread chiếm được S.
    • Thread nào chiếm được S trước thì sẽ sử dụng R trước. Thread nào không chiếm được S thì sẽ đi ngủ cho đến khi thread đầu tiên sử dụng xong R.

Như vậy, tại bất cứ thời điểm nào, tối đa chỉ có một thread được phép chiếm dụng binary semaphore, đồng nghĩa với việc, tối đa chỉ có một thread được phép sử dụng critical resource. Do đó, race condition sẽ không xảy ra và critical resource được bảo vệ.

Sử dụng các semaphore kernel API trong lập trình device driver

Để khai báo và khởi tạo giá trị cho binary semaphore ngay từ lúc biên dịch (compile time), ta có thể sử dụng macro DEFINE_SEMAPHORE. Ví dụ:

DEFINE_SEMAPHORE(my_semaphore); //khởi tạo trạng thái AVAILABLE cho my_semaphore

Tuy nhiên, semaphore thường nằm trong một cấu trúc lớn hơn và được cấp phát bộ nhớ trong quá trình chạy (run time). Do đó, ta sẽ dùng hàm sema_init để khởi tạo giá trị cho semaphore. Ta thường gọi hàm sema_init trong hàm khởi tạo của driver. Ví dụ:

/* * Khi ta muốn bảo vệ dữ liệu trong cấu trúc my_struct, ta sẽ nhúng * biến cấu trúc kiểu semaphore vào trong cấu trúc my_struct. * Biến cấu trúc my_struct_t đại diện cho critical resource, * còn my_semaphore đại diện cho bộ các chìa khóa bảo vệ critical resource. */ struct my_struct { ... struct semaphore my_semaphore; ... } my_struct_t; int init_driver_func() { ... //Giá trị khởi tạo lớn hơn hoặc bằng 0 sema_init(&my_struct_t.my_semaphore, 1); ... }

Sau khi đã khai báo và khởi tạo semaphore, ta có thể sử dụng cặp hàm downup lần lượt vào trước và sau critical section của thread để ngăn không cho race condition xảy ra.

down(&my_semaphore); /* critical section của kernel thread */ up(&my_semaphore);

Đôi khi, ta có thể sử dụng hàm down_interruptible thay cho hàm down. Cách sử dụng như sau:

/* * Ta có thể sử dụng "int down_interruptible(struct semaphore *sem)" * thay cho hàm "void down(struct semaphore *sem)". * Nếu chiếm được semaphore, hàm này sẽ trả về 0. * Nếu chưa chiếm được, thread (gọi hàm này) sẽ bị tạm ngừng hoạt động. * Nếu thread đang tạm ngừng hoạt động mà có một tín hiệu, hàm này trả về -EINTR. * * Khi nào sử dụng down_interruptible thay cho down? * Đó là khi ta muốn thread tiếp nhận các tín hiệu (signal) trong lúc * đang chờ semaphore. * * Xét trường hợp tiến trình P trên user space yêu cầu device driver * đọc/ghi dữ liệu trong critical resource R. Khi đó, tương ứng với P, * sẽ có một kernel thread T định truy cập vào R. Nếu kernel thread T' * đang truy cập R, thread T sẽ bị tạm dừng tại hàm down_interruptible. * Ta nói, thread T đang bị blocking bởi hàm down_interruptible. * Nếu lúc này người dùng tạo một tín hiệu (signal), ví dụ nhấn tổ hợp * CTRL + C để hủy tiến trình P, thì hàm down_interruptible * sẽ trả luôn về -EINTR mà không blocking thread T nữa. Điều này giúp hủy * tiến trình P luôn mà không phải chờ đợi thread T' giải phóng semaphore. */ if (down_interruptible(&my_semaphore)) return -ERESTARTSYS; /* critical section của kernel thread */ up(&my_semaphore);

Ngoài ra, Linux kernel hỗ trợ hàm down_trylock.

/* * hàm: down_trylock * chức năng: yêu cầu chiếm giữ semaphore. Nếu không thể chiếm được, * trả luôn về cho thread gọi hàm này. Thread gọi hàm này * sẽ không chờ đợi semaphore nữa (non-blocking). * tham số đầu vào: * *sem [IO]: là địa chỉ của vùng nhớ chứa cấu trúc semaphore. * giá trị trả về: * Nếu chiếm được semaphore, trả về 0. * Nếu không chiếm được semaphore (do thread khác đã chiếm rồi), trả về 1. */ int down_trylock(struct semaphore *sem);

Chú ý khi sử dụng semaphore

Khi triển khai giải pháp này, ta cần chú ý mấy điểm sau:

  • Do semaphore áp dụng cơ chế chờ đợi sleep-waiting, nên ta chỉ sử dụng kỹ thuật này khi khoảng thời gian chờ đợi dài. Thông thường, nếu critical section chứa lời gọi hàm sleep/schedule hoặc gồm nhiều câu lệnh, thì có thể áp dụng semaphore.
  • Kỹ thuật này hoàn toàn phù hợp để áp dụng trong các thread được phép đi ngủ, ví dụ như các kernel thread thông thường, hoặc bottom-half được triển khai bằng workqueue.
  • Ta không được phép gọi hàm down hoặc down_interruptible trong ISR, hoặc bottom-half được triển khai bằng tasklet/softirq. Tuy vậy, hàm down_trylockup vẫn có thể được gọi từ ISR.
  • Một thread có thể giải phóng semaphore mặc dù nó không phải là người đã chiếm dụng. Điều này khác so với kỹ thuật spinlock và mutex lock.
  • Trong khi đang chiếm dụng một spinlock, ta không được gọi hàm down_interruptible hoặc down để lấy một semaphore.

Case study

Trong ví dụ này, chúng ta sẽ áp dụng kỹ thuật semaphore để cải thiện vchar driver trong bài hôm trước. Đầu tiên, ta tạo thư mục cho bài học ngày hôm nay như sau:

cd /home/ubuntu/ldd/phan_6 cp -r bai_6_1 bai_6_5

Bây giờ, ta tiến hành sửa file vchar_driver.c. Đầu tiên, để triển khai semaphore, ta cần tham chiếu tới thư viện <linux/semaphore.h>.

Ứng dụng semaphore trong cấp phát tài nguyên

Tiếp theo, ta thêm biến vchar_semaphore trong cấu trúc _vchar_drv. Semaphore này giúp bảo vệ dữ liệu trong biến critical_resource.

Ứng dụng semaphore trong cấp phát tài nguyên

Sau đó, trong hàm vchar_driver_init, ta khởi tạo semaphore này để tạo ra binary semaphore:

Ứng dụng semaphore trong cấp phát tài nguyên

Cuối cùng, ta thêm hàm downup lần lượt vào trước vào sau vùng critical section.

Ứng dụng semaphore trong cấp phát tài nguyên

Bây giờ, ta gõ lệnh make để biên dịch lại vchar driver. Sau khi biên dịch thành công, ta thực hiện kiểm tra như hình 3 dưới đây và thấy rằng, kết quả cuối cùng của biến critical_resource đúng bằng 3,145,728. Tuy nhiên, có thể thấy rằng, nếu áp dụng kỹ thuật semaphore, thời gian để hoàn thành bài toán lâu hơn rất nhiều so với kỹ thuật spinlock và mutex lock.

Ứng dụng semaphore trong cấp phát tài nguyên

Hình 3. Sử dụng kỹ thuật binary semaphore giúp ngăn ngừa race condition trên biến critical_resource

Kết luận

Semaphore là một cấu trúc, vừa dùng để đồng bộ tài nguyên, vừa dùng để đồng bộ hoạt động. Semaphore gồm 2 thành phần chính là biến count và hàng đợi wait_list. Biến count giúp kiểm soát số lượng thread còn lại được phép truy cập vào critical resource. Còn hàng đợi wait_list chứa danh sách các thread đang phải chờ đợi trước khi có thể truy cập critical resource.

Semaphore gồm 2 loại là binary semaphore và counting semaphore. Hoạt động của binary semaphore tương tự như mutex lock, do đó thường được sử dụng để phòng tránh race condition. Điểm khác biệt nổi bật so với mutex lock đó là: một thread có thể giải phóng semaphore mặc dù thread đó chưa hề chiếm dụng semphore.