Thread Synchronization-Biến điều kiện

Trong bài trước, chúng ta đã tìm hiểu về cơ chế Mutex giúp cho việc bảo vệ biến toàn cục khi lập trình multi-thread. Bài này sẽ đi vào tìm hiểu cách sử dụng biến điều kiện (conditional variable) giúp cho các thread trong tiến trình giao tiếp và sử dụng biến toàn cục một cách hiệu quả hơn.

Biến điều kiện là một cơ chế cho phép một thread thông báo đến một hoặc nhiều thread khác trong tiến trình về việc thay đổi trạng thái của biến toàn cục và cho phép các thread khác chờ (block) một thông báo nào đó.

Bài toán Producer-Consumer

Để hiểu rõ hơn về bối cảnh cần sử dụng biến điều kiện, ta xét một ví dụ về bài toán producer-consumer rất hay gặp trong lập trình multi-thread như sau: Giả sử trong một tiến trình server, main thread tạo ra một thread khác và giao nhiệm vụ xử lý thông tin từ tất cả các client kết nối rồi lưu kết quả vào một queue buffer được lưu trên một dữ liệu toàn cục. Main thread tạo ra một vòng lặp lấy dần các kết quả từ buffer đó và xử lý từng cái một. Trong bài toán này, main thread được gọi là consumer, và thread được tạo ra kia được gọi là producer; nghĩa là một ông sản xuất, còn một ông tiêu thụ. Bài toán này được minh họa như sau:

Hình 1: Bài toán Producer-Consumer

Nhìn vào hình vẽ trên ta thấy phát sinh 2 trường hợp sau:

  • Trường hợp 1: Consumer thread đã lấy hết dữ liệu từ Buffer (buffer empty) và không biết bao giờ buffer tiếp tục có dữ liệu để xử lý, lúc này consumer vẫn phải tiếp tục vòng lặp để thăm dò trạng thái của buffer. Việc này khá tốn kém tài nguyên của CPU, đặc biệt trong những trường hợp provider không có dữ liệu thường xuyên.
  • Trường hợp 2: Buffer đã đầy dữ liệu và không thể tiếp tục thêm được nữa. Lúc này producer nếu cố thêm vào có thể gây ra việc mất mát dữ liệu, hoặc producer phải lặp lại việc kiểm tra xem buffer đã có chỗ trống để thêm vào hay chưa. Việc này cũng ảnh hưởng đến performance của tiến trình.

Trong hai trường hợp trên, chắc chúng ta đều mong có một cơ chế rằng: Consumer cứ ngủ đi, khi nào provider thêm dữ liệu vào thì nó thông báo cho; hoặc provider cứ ngủ đi, khi nào buffer hết đầy thì consumer gửi thông báo cho. Cơ chế này chính là conditional variable.

Cùng xét ví dụ sau đây để thấy được công dụng và cách sử dụng của biến điều kiện.

Trong ví dụ này, hàm main() cần biến toàn cục “counter” thay đổi trong hàm threadFunc() đến khi có giá trị bằng 5 (THRESHOLD) để thoát chương trình. Chúng ta có thể dùng cách chân phương nhất là dùng 1 vòng lặp vô hạn để polling giá trị của “counter” như dưới đây:

#include<stdio.h>
#include<string.h>
#include<pthread.h>
#include<stdlib.h>
#include<unistd.h>

#define THRESHOLD     5

pthread_t tid;

/*Counter la bien toan cuc duoc 2 thread su dung*/
int counter;
pthread_mutex_t mutex;   //khai bao mutex

void threadFunc(void)
{
   pthread_mutex_lock(&mutex);
   printf("pthread has startedn");

   while(counter < THRESHOLD)
   {
       counter += 1;
       sleep(5);
   }

   printf("pthread has finished, counter = %dn", counter);
   pthread_mutex_unlock(&mutex);

   pthread_exit(NULL);
}

int main(void)
{
   int ret = 0;

   ret = pthread_create(&tid, NULL, &(threadFunc), NULL);
   if (ret != 0)
   {
       printf("pthread created failn");
   }

   while(1)
   {
       if(counter == THRESHOLD)
       {
           printf("Global variable counter = %d.n", counter);
           break;
       }
   }

   pthread_join(tid, NULL);

   return 0;
}

Bây giờ, compile và chạy chương trình, ta sẽ thấy output là dòng “Biến toàn cục counter = 5” xuất hiện sau khoảng 25 giây:

Chương trình trên có thể chạy đúng yêu cầu của chúng ta, tuy nhiên việc vòng lặp while(1) trong hàm main() chạy liên tục để theo dõi biến counter rất lãng phí tài nguyên CPU, nhất là khi THRESHOLD có giá trị rất lớn hoặc nhiều thread có thể tăng giảm biến counter thất thường. Sẽ tuyệt vời hơn nếu hàm main() trong lúc chờ sẽ ngủ để tiết kiệm CPU và hàm threadFunc() sẽ đánh thức sau khi thấy biến counter đã có giá trị nó muốn. Đây chính là cơ chế hoạt động của biến điều kiện (condition variable) trong pthread.

Biến điều kiện cho phép một thread đăng ký đợi (đi vào trạng thái ngủ) cho đến khi có một thread khác gửi một signal đánh thức nó dậy để chạy tiếp.

Sử dụng biến điều kiện trong Posix thread

Để sử dụng biến điều kiện trong pthread, chúng ta dùng các API sau:

  • Khai báo biến điều kiện

Biến điều kiện trong pthread có kiểu dữ liệu phread_cond_t, có thể được khai báo tĩnh hoặc động như sau:

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

hoặc khai báo động:

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
/*Returns 0 khi thành công, hoặc 1 số dương mã lỗi*/

Với trường hợp khai báo động, “attr” là thuộc tính của biến điều kiện, có thể truyền NULL để thiết lập thuộc tính mặc định. Mỗi biến điều kiện được khai báo động sau khi không sử dụng nữa đều phải hủy bằng cách gọi hàm pthread_cond_destroy() có prototype như sau:

#include <pthread.h>

int pthread_cond_destroy(pthread_cond_t *cond);
     /*Trả về 0 nếu thành công, hoặc một số dương là mã lỗi nếu thất bại*/
  • Signal và Wait với biến điều kiện

Để đặt thread vào trạng thái ngủ và chờ signal, chúng ta gọi hàm pthread_cond_wait(). Khi một thread thấy điều kiện chờ thỏa mãn, nó sẽ gọi hàm pthread_cond_signal() hoặc pthread_cond_broadcast() để gửi signal đến thread đang chờ. Các hàm này có prototype như sau:

#include <pthread.h>

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
/*Return 0 khi thành công, hoặc 1 số dương mã lỗi*/

Đối số “cond” chính là biến điều kiện chúng ta khai báo ở trên, và “mutex” là địa chỉ biến mutex dùng cho biến toàn cục mà chúng ta đang cần theo dõi. Sự khác nhau giữa hàm pthread_cond_signal() và pthread_cond_broadcast là khi có nhiều thread cùng đang chờ signal với 1 biến điều kiện, pthread_cond_signal() chỉ gửi signal đến một trong số các thread đang chờ, trong khi hàm pthread_cond_broadcast() sẽ gửi signal đến tất cả các thread đang chờ trên biến điều kiện đó. Vì vậy, pthread_cond_signal() thường được dùng khi các thread chờ cùng thực hiện công việc giống nhau (chỉ cần 1 thread thức dậy thực hiện). Còn pthread_cond_broadcast() được dùng khi mỗi thread chờ thực hiện các công việc khác nhau sau khi nhận được signal.

Hàm pthread_cond_wait() sẽ đưa thread vào trạng thái ngủ và chỉ thức dậy sau khi nhận được signal trên biến điều kiện đó từ một thread khác.

Bây giờ, chúng ta sẽ áp dụng biến điều kiện vào ví dụ trên.

#include<stdio.h>
#include<string.h>
#include<pthread.h>
#include<stdlib.h>
#include<unistd.h>

#define THRESHOLD     5

pthread_t tid;

/*Counter la bien toan cuc duoc 2 thread su dung*/

int counter;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;   //khai báo mutex
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;      // khai bao bien dieu kien

void threadFunc(void)
{
   pthread_mutex_lock(&mutex);  /*Khoa mutex de bao ve bien counter*/
   printf("pthread has startedn");

   while(counter < THRESHOLD)
   {
       counter += 1;
       sleep(5);
   }

   pthread_cond_signal(&cond);
   printf("pthread has finished, counter = %dn", counter);
   pthread_mutex_unlock(&mutex);  /*Nha khoa mutex*/

   pthread_exit(NULL);
}

int main(void)
{
   int ret = 0;

   ret = pthread_create(&tid, NULL, &(threadFunc), NULL);
   if (ret != 0)
   {
       printf("pthread created failn");
   }

   pthread_mutex_lock(&mutex);  /*Khoa mutex de bao ve bien counter*/
   while(counter < THRESHOLD)  /*Kiem tra gia tri cua counter co dung nhu mong doi khong*/
   {
       pthread_cond_wait(&cond, &mutex);   /*Cho signal tu thread khac*/
       printf("Global variable counter = %d.n", counter);  /*Truy cap bien counter*/
   }

   pthread_mutex_unlock(&mutex);  /*Nha khoa mutex*/
   pthread_join(tid, NULL);

   return 0;
}

Compile và chạy chương trình trên, ta cũng sẽ được kết quả tương tự.

Đoạn code ở trên có thể được coi là một template về cách sử dụng biến điều kiện. Có 2 câu hỏi dễ được đặt ra trong đoạn code trên và ta cần giải thích là:

  • Tại sao pthread_cond_wait() để chờ một signal trên biến điều kiện “cond” lại cần một mutex đi kem?

Mục đích của biến điều kiện là giúp thread ngủ khi đang chờ một điều kiện nào đó của biến toàn cục. Do vậy trong ví dụ trên, theo quy trình thông thường, ta phải làm các việc sau:

No      Trình tự công việc Hàm thực hiện
1 Khóa mutex để truy cập biến toàn cục  pthread_mutex_lock(&mutex)
2 Kiểm tra giá trị biến toàn cục while(counter < THRESHOLD)
3 Nhả khóa mutex pthread_cond_wait(&cond, &mutex)
4 Đưa thread vào trạng thái chờ pthread_cond_wait(&cond, &mutex)
5 Đánh thức thread nếu nhận signal pthread_cond_wait(&cond, &mutex)
6 Khóa mutex để truy cập biến toàn cục pthread_cond_wait(&cond, &mutex)
7 Nhả khóa mutex pthread_mutex_unlock(&mutex)

Như vậy ta thấy rằng, một lần gọi hàm pthread_cond_wait() giúp thực hiện chuỗi các bước 3 đến bước 6. Đó là lý do hàm pthread_cond_wait() được thiết kế luôn đi cùng với một biến điều kiện và một mutex của biến toàn cục mà nó muốn truy cập.

  • Tại sao pthread_cond_wait() đánh thức thread khi “counter == THRESHOLD” rồi lại cần đặt trong while loop?

Về mặt thiết kế chức năng, hàm pthread_cond_wait() đánh thức thread sau khi nhận được một signal từ một thread nào đó. Do vậy, không có gì đảm bảo chắc chắn điều kiện của biến toàn cục của thread đúng như nó mong đợi sau khi thức dậy. Việc đặt hàm pthread_cond_wait() trong while loop kiểm tra điều kiện để đưa thread vào trạng thái chờ tiếp nếu không may sau khi thức dậy mà điều kiện của biến toàn cục không như nó mong đợi.

Kết luận

Kết thúc bài học này, chúng ta đã nắm được các vấn đề của đồng bộ khi xử lý tài nguyên toàn cục của Posix thread. Biến điều kiện thường được dùng để giải quyết bài toán producer-consumer, một tình huống thường xuyên gặp trong lập trình multi-thread, giúp cho các thread giao tiếp và sử dụng tài nguyên CPU hiệu quả hơn. Trong bài học sau, chúng ta sẽ đi tìm hiểu về cơ chế quản lý bộ nhớ (memory management) của hệ điều hành Linux.

Tài liệu tham khảo

The Linux programming interface – Michael Kerrisk – Chapter 30

Programming with Posix Threads – David R. Butenhof

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *