Nghiên cứu về :Thread, Semaphore, Mutex and Condition variable

1,THREAD

Biết cách lập trình với thread và multithread là một trong những kỹ năng cần thiết của một programmer tốt. Trong bài viết này sẽ đề cập về POSIX (Portable Operating System Interface) threads. Như bạn đã biết POSIX (chính xác hơn là chuẩn IEEE 1003.1c của tổ chức IEEE đưa ra) bao gồm những định nghĩa “giao diện” chung cho các hệ điều hành. Điều đó có nghĩa là những hệ điều hành nào support POSIX (GNU/Linux, BSD, Sun Solaris, Unix, …) thì đều có những system call có prototype giống như trong tài liệu về POSIX đưa ra, mặc dù đối với mỗi hệ điều hành có cách implement khác nhau. POSIX threads (Pthreads) là một cách rất
tốt để làm tăng độ tin cậy và performance cho chương trình.

Threads cũng tương tự như processes, đều được phân chia thời gian bởi kernel. Với hệ thống chỉ có một bộ vi xử lý thì kernel sử dụng cách phân chia thời gian để “làm cho” các threads như là chạy đồng thời theo cùng cách thức kernel thực hiện với processes. Và với các hệ thống đa nhân thì các threads thực sự có thể chay đồng thời giống như là nhiều processes. Thế thì tại sao multithread lại được ưa chuộng hơn là nhiều process độc lập đối với các task có mối quan hệ với nhau? Đó là bởi vì các threads sử dụng chung cùng một không gian bộ nhớ. Mỗi thread độc lập đều có thể truy nhập vào cùng một biến toàn cục trong bộ nhớ. Trong khi fork() cho phép tạo ra nhiều process nhưng rất khó khăn trong việc trao đổi thông tin giữa process với nhau vì mỗi process có một không gian vùng nhớ riêng. Không có một câu trả lời đơn giản cho việc trao đổi giữa các process (IPC). Do vậy mà multiprocess programming sẽ phải chịu 2 trở ngại lớn:
– Perforamance thấp vì khi tạo một process mới đòi hỏi kernel thực thi nhiều phép tính toán để cấp phát bộ nhớ.
– Trong hầu hết các trường hợp thì IPC làm chương trình trở nên phức tạp hơn rất nhiều.
Hơn thế nữa, quá tải và sự phức tạp không phải là những thứ tốt. Nếu bạn đã từng phải làm những thay đổi lớn cho một chương trình của bạn mà support IPC, sẽ rất tốt nếu sử dụng cách tiếp cận chia sẻ vùng nhớ đơn giản của threads. Pthreads không cần phải những lời gọi expensive và phực tạp bởi vì threads “sống cùng một ngôi nhà”. Bạn không phải đẩy dữ liệu thông qua một file hoặc một vùng nhớ nào đó. Chính vì lý do này mà bạn nên cân nhặc mô hình một process/nhiều threads hơn là nhiều process/một thread.

Như đã nói ở trên, tạo một thread nhanh hơn rất nhiều (từ 10 cho đến 100 lần) so với tạo một process. Kernel không cần phải tạo một bản copy độc lập của không gian bộ nhớ hiện thời, bảng các mô tả file (file descriptors). Điều đó tiết kiệm rất nhiều thời gian của CPU. Chúng ta sẽ xem xét một ví dụ.

C Code for fork() creation test

==============================================================================

#define NFORKS 50000
void do_nothing()
{
int i;
i= 0;
}
main()
{
int pid, j, status;
for (j=0; j<NFORKS; j++) {
/*** error handling /
if ((pid = fork()) < 0 ) {
printf (“fork failed with error code= %d\n”, pid);
exit(0);
}
/ this is the child of the fork /
else if (pid ==0) {
do_nothing();
exit(0);
}
/ this is the parent of the fork ***/

else {
waitpid(pid, status, 0);
}
}
}

==============================================================================

C Code for pthread_create() test

#include <pthread.h>
#define NTHREADS 50000
void *do_nothing(void *null)
{
int i;
i=0;
pthread_exit(NULL);
}
main()
{
int rc, i, j, detachstate;
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);

for (j=0; j<NTHREADS; j++) {
rc = pthread_create(&tid, &attr, do_nothing, NULL);
if (rc) {
printf(“ERROR; return code from pthread_create() is %d\n”, rc);
exit(-1);
}
/* Wait for the thread */
rc = pthread_join(tid, NULL);
if (rc) {
printf(“ERROR; return code from pthread_join() is %d\n”, rc);
exit(-1);
}
}
pthread_attr_destroy(&attr);
pthread_exit(NULL);
}

Tuy nhiên thì multithread cũng có điểm yếu nhất định. Đó chính là khả năng đổ vỡ của nó. Bởi vì các threads ở trong cùng một không gian bộ nhớ nên nếu một thread đổ vỡ (crash) sẽ kéo theo toàn bộ process và các thread khác bị terminate. Trái lại thì khi một process bị đổ vỡ thì không ảnh hưởng gì đến các process khác. Chính vì vậy ở những hệ thống lớn thì họ lại sử dụng các daemon là multiprocess hơn là multithread. Note: Trong phần này chúng ta chú ý đến 2 lệnh:
rc = pthread_create(&tid, &attr, do_nothing, NULL);
Đây là tạo một Thread và nó sẽ goto function do_nothing rc return 0 nếu thành công
rc = pthread_join(tid, NULL);
wait cho thread này hoạt động xong hoàn thành thì rc = 0;
/* Note for a simple code /
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int myglobal;
void *thread_function(void *arg) {
int i,j;
for ( i=0; i<20; i++ ) {
j=myglobal;
j=j+1;
printf(“.”);
fflush(stdout);
sleep(1);
myglobal=j;
}
return NULL;
}
int main(void) {
pthread_t mythread;
int i;
if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
printf(“error creating thread.”);
abort();
}
for ( i=0; i<20; i++) {
myglobal=myglobal+1;
printf(“o”);
fflush(stdout);
sleep(1);
}
if ( pthread_join ( mythread, NULL ) ) {
printf(“error joining thread.”);
abort();
}
printf(“\nmyglobal equals %d\n”,myglobal);
exit(0);
}
/Hãy suy nghĩ khi chạy chương trình này và đoán xem biến : myglobal sẽ có giá trị là bao

nhiêu và tại sao. Đây là một lỗi nên chúng ta phải control nó và chúng ta sử dụng Mutexs cho vấn
đề này */
2,MUTEX

/* Code sample */
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int myglobal;
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
void *thread_function(void *arg)
{
int i,j;
for ( i=0; i<20; i++ ) {
pthread_mutex_lock(&mymutex);
j=myglobal;
j=j+1;
printf(“.”);
fflush(stdout);
sleep(1);
myglobal=j;
pthread_mutex_unlock(&mymutex);
}
return NULL;
}
int main(void)
{
pthread_t mythread;
int i;
if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
printf(“error creating thread.”);
abort();
}
for ( i=0; i<20; i++) {
pthread_mutex_lock(&mymutex);
myglobal=myglobal+1;
pthread_mutex_unlock(&mymutex);
printf(“o”);
fflush(stdout);
sleep(1);
}
if ( pthread_join ( mythread, NULL ) ) {
printf(“error joining thread.”);
abort();
}
printf(“\nmyglobal equals %d\n”,myglobal);
exit(0);
}
Nếu chúng ta so sánh đoạn mã này với đoạn mã trong chương trình thread2.c, chúng ta sẽ thấy có thêm những lời gọi hàm pthread_mutex_lock() và thread_mutex_unlock(). Những lời gọi hàm này rất cần thiết trong lập trình với thread. Chúng cung cấp cách thức loại trừ lẫn nhau (mutual exclusion) – có nghĩa là khi một thread sử dụng tài nguyên thì thread khác không được sử dụng và phải chờ cho đến khi thread kia giải phóng tài nguyên. Không có hai thread nào có thể cùng
có một mutex bị khóa ở một thời điểm.
Đó chính là cách thức mutex làm việc. Nếu thread “a” cố gắng khóa một mutex trong khi thread “b” đã khóa cùng mutex đó rồi thì thread “a” sẽ rơi vào trạng thái ngủ (sleep). Ngay khi thread “b” giải phóng mutex (thông qua pthread_mutex_unlock()), thread “a” sẽ có thể khóa mutex (nói một cách khác thì khi đó giá trị của hàm pthread_mutex_lock() sẽ được trả về với mutex bị lock).
Ngược lại, nếu thread “c” cố gắng khóa mutex trong khi thread “a” đang nắm giữ nó thì thread “c” cũng sẽ bị rơi vào trạng thái ngủ tạm thời. Tất cả thread mà cùng rơi vào trạng thái ngủ từ lệnh gọi pthread_mutex_lock() vì một khóa mutex đã bị lock sẵn rồi sẽ được đưa vào hàng đợi để truy nhập vào mutex.
pthread_mutex_lock() và pthread_mutex_unlock() thường được sử dụng để bảo vệ cấu trúc dữ liệu. Nó đảm bảo rằng chỉ có một thread tại một thời điểm có thể truy nhập vào cấu trúc dữ liệu bằng việc khóa và mở khóa. Bạn cũng có thể đoán ra rằng POSIX threads sẽ cấp một khóa mà không đưa thread đó vào trạng thái ngủ khi nó cố gắng khóa một mutex không bị khóa.
Thế nhưng tại sao chúng ta lại phải đưa các thread vào trạng thái sleep? Rõ ràng nó không phải là ưu điểm chính của thread khi mà ta muốn tận dụng khả năng làm việc độc lập và đồng thời của các thread? Đúng vậy. Tuy nhiên thì các chương trình sử dụng thread vẫn thường cần phải sử dụng mutex.
Nếu bạn nhìn vào hàm thread_function(), bạn sẽ nhận ra rằng mutex bị khóa vào lúc đầu vòng lặp và giải phóng khi kết thúc. Trong ví dụ này thì mymutex được dùng để bảo vệ giá trị của myglobal. Nếu nhìn cẩn thận hơn, sẽ thấy rằng đoạn mã tăng giá trị của myglobal trong thread_function sẽ copy giá trị myglobal và một biến local và ghi trở lại từ local vào myglobal sau khi tăng (đoạn mã sleep một giây có thể bỏ đi. Chẳng có lý do gì để thread_function phải sleep khi đã sử dụng mutex. Nó được thêm vào để có thể nhìn rõ hơn sự khác biệt).

3, CONDITION VARIABLE
Chúng ta đã biết cách thức dùng mutex để đồng bộ hóa giữa các thread, tránh xung đột giữa các thread khi cùng truy cập tài nguyên. Tuy nhiên thì không phải mutex
làm được tất cả. Ví dụ như chúng ta sẽ làm thế nào nếu chúng ta muốn thread đợi một điều kiện nào đó xảy ra với dữ liệu trong vùng chia sẻ? Chắc chắn là chúng ta sẽ làm bằng cách lặp liên tục việc khóa và mở khóa mutex (để đảm bảo đồng bộ giữa các thread cùng truy nhập vào vùng dữ liệu chia sẻ) và kiểm tra bất cứ sự thay đổi
nào trên dữ liệu. Cùng lúc đó sẽ rất nhanh chóng thread sẽ mở khóa mutex cho các thread khác có thể thực hiện sự thay đổi trên vùng dữ liệu đó. Như vậy thì cách tiếp
cận này thật kinh khủng bởi vì chúng ta sẽ cần một vòng lặp busy-loop để nhận ra sự thay đổi ở vùng dữ liệu. Nó thật là “lãng phí CPU”. (Bạn cần phải phân biệt giữa
một chương trình ở trạng thái idle và busy!!!).
Chúng ta có thể để cho thread “ngủ” một chút, ví dụ 3 giây chẳng hạn ở giữa mỗi một lần kiểm tra, nhưng rõ ràng nó không tối ưu tuyệt đối. Cái chúng ta cần là phải
đưa thread đó vào trạng thái ngủ cho đến khi một “điều kiện” nào đó được thỏa mãn.
Một khi điều kiện được thỏa mãn thì nó sẽ đánh thức thread của chúng ta để nó tiếp tục sử lý. Đó chính là một kiểu cơ chế báo hiệu (signal). Khi có tín hiệu thì thread
mới được đánh thức để sử lý tín hiệu đó.
Trong bài này chúng ta sẽ sử dụng biến pthread_condition_t để thể hiện cách thức báo hiệu này. Biến pthread_condition_t có cách sử dụng cũng tương tự như biến mutex. Chúng ta khai báo và khởi tạo như sau:
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;
hoặc sử dụng
int pthread_cond_init(pthread_cond_t cond, pthread_cond_attr_tcond_attr);
Nhiều thread có thể thực hiện việc chờ trên cùng một biến điều kiện. Khi một thread nào đó muốn báo hiệu việc thay đổi cho các thread đang chờ thì nó sẽ phát tín hiệu
đến biến điều kiện đó. Khi đó thì các thread đang chờ trên biến điều kiện đó sẽ thoát khỏi trạng thái “ngủ”. Để phát tín hiệu đến biến điều kiện chúng ta dùng
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
pthread_cond_broadcast gửi tín hiệu kiểu broadcast sẽ đánh thức toàn bộ các thread đang chờ. Để chờ một biến điều kiện, chúng ta hy vọng một hàm pthread_cond_wait() chấp nhận một biến pthread_cond_t làm tham số. Khi hàm pthread_cond_wait được gọi nó sẽ rơi vào trạng thái ngủ cho tới khi biến điều kiện được đánh thức và đánh thức nó. Khi nó được đánh thức là lúc hàm pthread_cond_wait trả về. Một vấn đề quan trọng là biến điều kiện cũng phải đồng bộ hóa, nghĩa là khi một thread đang chờ trên biến điều kiện cond thì các thread khác không được thay đổi thuộc tính, hay hủy biến điều kiện đó. Thế nhưng bản thân biến điều kiện pthread_cond_t lại không cung cấp cơ chế này nên thường chúng ta sẽ phải sử dụng chung với một biến mutex.
Đầu tiên chúng ta khóa mutex để việc chờ đợi trên biến điều kiện là đồng bộ:

int rc = pthread_mutex_lock(&a_mutex);
Sau khi khóa mutex chúng ta sẽ gọi
rc = pthread_cond_wait(&a_cond, &a_mutex);
để thực hiện việc chờ đợi trên biến điều kiện
int pthread_cond_wait(pthread_cond_t* , pthread_mutex_t* );
int pthread_cond_timewait(pthread_cond_t* , pthread_mutex_t* , timespec);
Việc đầu tiên pthread_cond_wait() làm gần như đồng thời là unlock biến mutex a_mutex (do đó các thread khác có thay đổi vùng dữ liệu chờ đợi) và chờ đợi trên
biến điều kiện (do đó pthread_cond_wait() sẽ thức dậy khi nó được báo hiệu bởi thread khác). Bây giờ thì a_mutex đã bị unlock và các thread khác có thể thay đổi
vùng nhớ dữ liệu đang theo dõi.
Lúc này thì pthread_cond_wait() vẫn chưa trả về. Việc unlock mutex sảy ra ngay lập tức nhưng việc chờ đợi trên biến điều kiện a_cond thì sẽ block thread của chúng ta, có nghĩa là thread đó sẽ sleep và không tiêu tốn một chu kỳ máy CPU nào cho đến khi nó thức dậy. Nhìn từ phía thread thì nó đơn giản chỉ là chờ đợi hàm
pthread_cond_wait() trả về. Khi pthread_cond_wait() trả về chính là lúc thread đó “thức giấc” và xử lý tiếp tục các lệnh tiếp theo ở thread sau pthread_cond_wait().
Giả sử có một thread khác là thread 2 làm nhiệm vụ lock a_mutex sau đó thay đổi dữ liệu trên một biến “a” nào đó, unlock a_mutex rồi gửi tín hiệu báo hiệu biến “a”
thay đổi đến biến điều kiện a_cond bằng pthread_cond_signal(&a_cond). Ngay khi thread 1 ở trên gọi pthread_cond_wait(&a_cond, &a_mutex) thì nó sẽ unlock a_mutex, do đó lúc này thì thread 2 bắt đầu bước vào thực hiện. Thread 2 sẽ khóa mutex thành công, thay đổi trên biến “a”, unlock và gửi tín hiệu đến biến a_cond.
Lúc này ở thread 1 đang bị blocking, hàm pthread_cond_wait chưa trả về. Ngay khi thread 2 gửi tín hiệu đến a_cond thì nó sẽ đánh thức thread 1 và hàm
pthread_cond_wait trả về. Chú ý rằng ngay khi pthread_cond_wait trả về thì nó cũng ngay lập tức khóa lại biến mutex. Nó sẽ xử lý tín hiệu và chúng ta sẽ unlock sau khi xử lý xong ở thread 1.
Tóm lại các bước chờ đợi một biến điều kiện diễn ra như sau.
int rc = pthread_mutex_lock(&a_mutex);
rc = pthread_cond_wait(&a_cond, &a_mutex);
if (rc == 0) {
// Do something
}
pthread_mutex_unlock(&a_mutex);
Vậy là chúng ta đã sẵn sàng viết những chương trình multithread thật hay.

4,SEMAPHORE
/Đoạn code về semaphore với chức năng tăng giảm semaphore/
/* Includes /
#include <unistd.h> / Symbolic Constants /
#include <sys/types.h> / Primitive System Data Types /
#include <errno.h> / Errors /
#include <stdio.h> / Input/Output /
#include <stdlib.h> / General Utilities /
#include <pthread.h> / POSIX Threads /
#include <string.h> / String handling /
#include <semaphore.h> / Semaphore /
/ prototype for thread routine /
void handler ( void *ptr );
/ global vars /
/ semaphores are declared global so they can be accessed

in main() and in thread routine,
here, the semaphore is used as a mutex /
sem_t mutex;
int counter; / shared variable /
int main()
{
int i[2];
pthread_t thread_a;
pthread_t thread_b;
i[0] = 0; / argument to threads /
i[1] = 1;
sem_init(&mutex, 0, 1); / initialize mutex to 1 – binary

semaphore /
/ second param = 0 – semaphore is

local /
/ Note: you can check if thread has been successfully created by

checking return value of
pthread_create /
pthread_create (&thread_a, NULL, (void *) &handler, (void *) &i[0]);
pthread_create (&thread_b, NULL, (void *) &handler, (void *) &i[1]);
pthread_join(thread_a, NULL);
pthread_join(thread_b, NULL);
sem_destroy(&mutex); / destroy semaphore /
/ exit /
exit(0);
} / main() /
void handler ( void *ptr )
{
int x;
x = *((int *) ptr);
printf(“Thread %d: Waiting to enter critical region…\n”, x);
sem_wait(&mutex); / down semaphore /
/ START CRITICAL REGION /
printf(“Thread %d: Now in critical region…\n”, x);
printf(“Thread %d: Counter Value: %d\n”, x, counter);
printf(“Thread %d: Incrementing Counter…\n”, x);
counter++;
printf(“Thread %d: New Counter Value: %d\n”, x, counter);
printf(“Thread %d: Exiting critical region…\n”, x);
/ END CRITICAL REGION /
sem_post(&mutex); / up semaphore /
pthread_exit(0); / exit thread */

}
Note: Semaphore trong ví dụ trên thì đầu tiên chúng ta init semaphore
sem_init(&mutex, 0, 1);
trong ham init thi second parameter =0; cho semaphore là biến local cho một chương
trình và khác 0 thì cho nhiều xử lý.thirth parameter là cho phép có gọi bao nhiêu thread trong quá trình thực hiện semaphore mỗi lần thực hiện thì hàm sem_wait();
giảm semaphore xuống
Trong hàm handler ():
+ sem_wait(&mutex): chức năng giảm semaphore
+ sem_pos(&mutex): chức năng tăng semaphore

 

From tuongbv