Bài trước mình đã giới thiệu với các bạn về thread và lập trình đa luồng. Nhưng trong lập trình đa luồng nếu không cẩn thận sẽ gặp rất nhiều lỗi khiến cho chương trình hoạt động không đúng như các bạn mong muốn và rất khó để debug nến như các bạn không hiểu bên dưới hệ thống đang thực sự hoạt động như thế nào. Bài này mình nói về chủ đề synchronization - động bộ trong lập trình đa luồng.


Đầu tiên chúng ta cần hiểu một khái niệm quan trọng lập trình đa luồng:

Critical section

Thuật ngữ Critical section dùng để chỉ đoạn code mà truy cập vào tài nguyên toàn cục (global resource); đoạn code này nên được chạy hết từ đầu đến cuối bởi mỗi thread thay vì bị ngắt khi có một thread khác xen vào đọc hoặc thay đổi các biến toàn cục trong đó.

Critical nên được bảo vệ để khi các thread đồng thời chạy vào, nó phải chờ cho đến khi thread đang chạy thực thi xong và ra khỏi thì mới được chạy vào. Việc này gọi là đồng bộ thread (thread synchronization), một công việc bắt buộc khi lập trình đa luồng.


Lỗi lớn nhất trong multithread đấy chính race-condition. Một lỗi muôn thuở và vấn đấy nhức nhối trong lập trình đa luồng mà bất kì lập trình viên trong mảng cũng phải đối mặt:


Race Condition

Tình trạng chạy đua (race condition) xảy ra khi hai hoặc nhiều luồng có thể truy cập dữ liệu chia sẻ và chúng cố gắng thay đổi dữ liệu đó cùng lúc. Vì thuật toán lập lịch luồng có thể hoán đổi giữa các luồng bất cứ lúc nào, bạn không biết thứ tự các luồng sẽ cố gắng truy cập dữ liệu chia sẻ. Do đó, kết quả của việc thay đổi dữ liệu phụ thuộc vào thuật toán lập lịch luồng, tức là cả hai luồng đều "chạy đua" để truy cập/thay đổi dữ liệu.

Vấn đề thường xảy ra khi một luồng thực hiện "kiểm tra rồi hành động" (ví dụ: "kiểm tra" nếu giá trị là X, sau đó "hành động" để thực hiện điều gì đó phụ thuộc vào giá trị là X) và một luồng khác thực hiện điều gì đó với giá trị nằm giữa "kiểm tra" và "hành động". Ví dụ:

cpp

if (x == 5)  
{
  y = x*2
  //Nếu có thread nào thay đổi giữa khoảng (x==5) và y=x*2,
  // thì  y sẽ không thể = 10.   
}

Vấn đề là, y có thể là 10, hoặc bất kỳ giá trị nào, tùy thuộc vào việc liệu một luồng khác có thay đổi x giữa lệnh kiểm tra à lệnh hành động hay không. 

Sẽ có một số kỹ thuật mà chúng ta có thể dùng để thực hiện việc bảo vệ tài nguyên và khắc phục những lỗi ở trên ví dụ:

- Mutex

- Semaphores

- Spinlock

Bài viết này mình sẽ tập trung nói về mutex và những API đã được cung cấp trong thư viện <pthread.h> trước. 


Mutex

Kỹ thuật Mutual Exclusion (Mutex) dùng để bảo vệ tài nguyên chia sẻ (shared resource) mà chủ yếu là biến chia sẻ (shared variable) giữa các thread. Hệ thống sử dụng mutex lock sẽ hoạt động theo các bước như dưới đây:

  • Giả sử một luồng đã khóa một vùng mã bằng mutex và đang thực thi đoạn mã đó.
  • Bây giờ nếu bộ lập lịch (scheduler) quyết định thực hiện chuyển ngữ cảnh (context switch), thì tất cả các luồng khác đang sẵn sàng thực thi cùng vùng mã sẽ được đánh thức.
  • Chỉ một trong số các luồng đó sẽ được thực thi, nhưng nếu luồng này cố gắng chạy cùng vùng mã đã bị khóa thì nó sẽ lại đi vào trạng thái ngủ.
  • Việc chuyển ngữ cảnh sẽ xảy ra lặp đi lặp lại, nhưng không luồng nào có thể thực thi vùng mã đã bị khóa cho đến khi mutex được giải phóng.
  • Mutex chỉ được giải phóng bởi chính luồng đã giữ khóa trước đó.
  • Điều này đảm bảo rằng một khi một luồng đã khóa một phần mã, thì không luồng nào khác có thể thực thi vùng mã đó cho đến khi nó được mở khóa bởi chính luồng đã khóa nó.
  • Do đó, hệ thống này đảm bảo sự đồng bộ giữa các luồng khi làm việc với tài nguyên dùng chung

Cần lưu ý rằng chỉ có thread nào khóa mutex thì mới mở được mutex đó, các thread khác sẽ không có quyền mở hay xóa. Việc thực hiện đồng bộ thread để bảo vệ tài nguyên chia sẻ gồm 3 bước:

  1. Khóa mutex trước khi vào critical section.
  2. Thực thi code trong critical section.
  3. Nhả khóa mutex sau khi kết thúc critical section.


Khởi tạo

Để sử dụng mutex, trước hết chúng ta phải khai báo và khởi tạo mutex. Trong Posix thread, biến mutex là kiểu dữ liệu có dạng pthread_mutex_t  và có thể được khởi tạo tĩnh sử dụng macro PTHREAD_MUTEX_INITIALIZER hoặc khởi tạo động lúc runtime:

  • Khởi tạo tĩnh (statically allocation): 
pthread_mutex_t  mutex = PTHREAD_MUTEX_INITIALIZER;


  • Khởi tạo động (dynamically initializing):
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
// Trả về 0 nếu thành công hoặc 1 số dương mã lỗi.


Trong cách khởi tạo tĩnh, macro PTHREAD_MUTEX_INITIALIZER dùng để khởi tạo một mutex với các thuộc tính (thread attribute) mặc định. Trong khi hàm pthread_mutex_init() trong cách khởi tạo động cho phép khởi tạo và thiết lập thuộc tính cho mutex. Nếu không cần quan tâm đến thuộc tính của thread, ta có thể truyền NULL vào đối số pthread_mutexattr_t *attr. Khi khởi tạo động mutex bằng hàm pthread_mutex_init(), ta cần phải hủy mutex đó nếu không cần sử dụng nữa bằng hàm pthread_mutex_destroy() có prototype như sau (khởi tạo tĩnh bằng macro PTHREAD_MUTEX_INITIALIZER không cần destroy mutex):

int pthread_mutex_destroy(pthread_mutex_t *mutex);
// Return 0 nếu thành công hoặc một số dương mã lỗi nếu không thành công.


Lock/Unlock Mutex

Sau khi khởi tạo, mutex được khóa và mở khóa bởi 2 hàm sau đây:

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// Trả về 0 nếu thành công hoặc 1 số dương mã lỗi nếu có lỗi xảy ra.


Mutex Deadlock

Lock Unlock là một cơ chế rất đơn giản và hiệu quả để bảo vệ biến toàn cục được sử dụng không chỉ trong lập trình multi-thread mà còn rất nhiều tình huống khác chung tài nguyên chia sẻ như đa tiến trình hoặc đọc/ghi vào cơ sở dữ liệu.

Khi một thread gọi hàm yêu cầu khóa để vào critical section mà có một thread khác đang khóa, nó sẽ chờ cho đến khi thread kia mở khóa rồi chiếm quyền.

Trạng thái một thread khóa một mutex mà rơi vào trạng thái chờ không thể giải thoát được gọi là deadlock. Đây là tình trạng xảy ra khi bạn không thể kiểm soát được thời gian giữa các thread và rất dễ xảy ra ở cả người mới và có kinh nghiệm.


Ví dụ:

#include <pthread.h>
#include <stdio.h>

pthread_t thread_id[2];
int counter;
pthread_mutex_t mtx;

void* thread_function(void* arg) {
    pthread_mutex_lock(&mtx);
    counter += 1;
    printf("Counter at start: %d\n", counter);
    for (unsigned long i = 0; i < 0xFFFFFFFF; i++);
    printf("Counter at end: %d\n", counter);
    pthread_mutex_unlock(&mtx);
    return NULL;
}

int main() {
    pthread_mutex_init(&mtx, NULL);
    for (int i = 0; i < 2; i++)
        pthread_create(&thread_id[i], NULL, thread_function, NULL);
    pthread_join(thread_id[0], NULL);
    pthread_join(thread_id[1], NULL);
    pthread_mutex_destroy(&mtx);
    return 0;
}  

Giải thích: 

  • Một mutex được khởi tạo ở phần đầu của hàm main.
  • Cùng mutex đó được khóa trong hàm thread_function() khi sử dụng tài nguyên dùng chung counter.
  • Ở cuối hàm thread_function(), mutex đó được mở khóa.
  • Cuối hàm main, khi cả hai luồng đã hoàn thành, mutex sẽ được hủy.

Kết quả:


Condition variable

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 đó.

Biến điều kiện giúp cho các thread giao tiếp và sử dụng tài nguyên CPU hiệu quả hơn.

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

Biến điều kiện trong pthread có kiểu dữ liệu pthread_cond_t, có thể được 

  • khai báo tĩnh như sau:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;


  • hoặc khai báo động như sau:
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
// Return 0 khi thành công hoặc một số dương là mã lỗi nếu thất bạ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:
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 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.


Ví dụ:

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

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int ready = 0;

void* waiter(void* arg) {
    pthread_mutex_lock(&lock);

    printf("Waiter: Waitting ... \n");

    pthread_cond_wait(&cond, &lock);

    printf("Waiter: received signal! \n");

    pthread_mutex_unlock(&lock);
    return NULL;
}

void* signaler(void* arg) {
    sleep(2);  // Giả lập xử lý gì đó mất thời gian

    pthread_mutex_lock(&lock);

    printf("Signaler: setting ready = 1 and signaling...\n");

    pthread_cond_signal(&cond);  // Đánh thức 1 thread đang wait

    sleep(2);  // Giả lập xử lý gì đó mất thời gian

    pthread_mutex_unlock(&lock);
    return NULL;
}

int main() {
    pthread_t t1, t2;

    pthread_create(&t1, NULL, waiter, NULL);
    pthread_create(&t2, NULL, signaler, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    pthread_mutex_destroy(&lock);
    pthread_cond_destroy(&cond);

    return 0;
}

Giải thích:

  • thread 1 chạy trước, khoá mutex rồi đợi tín hiệu của cond.
  • khi đợi tín hiệu thread sẽ rơi vào trạng thái ngủ và tạm thời nhả mutex
  • thread 2 lấy khoá mutex và signal cho các thread khác đợi biến này thức dậy
  • thread 1 thức dậy những chưa thể tiếp tục mà phải đợi thread 2 nhả khoá mutex mới có thể tiếp tục

Kết quả: