Thread và Posix Thread

Trong các bài trước, chúng ta đã được học về một thành phần rất quan trọng của hệ điều hành là tiến trình và cách hệ điều hành Linux tạo ra cũng như quản lý tiến trình. Trong chương này, chúng ta sẽ tìm hiểu một thành phần quan trọng tiếp theo và có liên quan rất chặt chẽ với tiến trình là luồng. Luồng với tên tiếng anh là Thread được sử dụng rất nhiều trong lập trình Linux nhằm thực hiện đa nhiệm cho tiến trình và tối ưu hóa performance của hệ thống. Vì thread là thuật ngữ đã quá phổ biến và xuất hiện trong tất cả các cuốn sách và các website về Linux, nên từ phần sau của khóa học chúng ta sẽ dùng thuật ngữ thread thay cho luồng.

Hãy cùng bắt đầu bằng việc tìm hiểu tổng quan về cách hoạt động của thread, cách một thread được tạo ra cũng như kết thúc, từ đó có cái nhìn tổng thể khi nào thì nên dùng và không nên dùng thread. Tiếp theo, chúng ta sẽ làm quen với các hàm API của thư viện Posix thread (pthread), một thư viện tiêu chuẩn cho lập trình thread được sử dụng thống trị trong lập trình Linux.

Tổng quan về thread

Thread là một cơ chế cho phép một ứng dụng thực thi đồng thời nhiều công việc (multi-task). Ví dụ một trường hợp đòi hỏi multi-task sau: một tiến trình web server của một trang web giải trí phải phục vụ hàng trăm hoặc hàng nghìn client cùng một lúc. Công việc đầu tiên của tiến trình là lắng nghe xem có client nào yêu cầu dịch vụ không. Giả sử có client A kết nối yêu cầu nghe một bài nhạc, server phải xử lý chạy bài hát client A yêu cầu; nếu trong lúc đó client B kết nối yêu cầu tải một bức ảnh thì server lúc đó không thể phục vụ vì đang bận phục vụ client A. Đây chính là kịch bản yêu cầu một tiến trình cần thực thi multi-task. Qua các bài học về process, ta thấy tiến trình server nói trên có thể giải quyết bài toán này như sau: Server chỉ làm công việc chính là lắng nghe xem có client nào yêu cầu dịch vụ không; khi tiến trình A kết nối, server dùng system call fork() để tạo ra một tiến trình con chỉ làm công việc client A yêu cầu, trong khi nó lại tiếp tục lắng nghe các yêu cầu từ các client khác. Tương tự, khi client B kết nối, server lại tạo ra một tiến trình con khác phục vụ yêu cầu của client B. Trong bài học này, chúng ta sẽ thấy việc xử lý đa tác vụ của server như trên có thể dùng thread. Thậm chí trong trường hợp này thread còn tỏ ra thích hợp hơn so với việc sử dụng tiến trình con như đã giải thích ở trên, chúng ta sẽ tìm hiểu rõ hơn trong bài này.

Thread là một thành phần của tiến trình, một tiến trình có thể chứa một hoặc nhiều thread. Hệ điều hành Unix quan niệm rằng mỗi tiến trình khi bắt đầu chạy luôn có một thread chính (main thread); nếu không có thread nào được tạo thêm thì tiến trình đó được gọi là đơn luồng (single-thread), ngược lại nếu có thêm thread thì được gọi là đa luồng (multi-thread). Các thread trong tiến trình chia sẻ các vùng nhớ toàn cục (global memory) của tiến trình bao gồm initialized data, uninitialized data và vùng nhớ heap. Nếu các bạn chưa nắm rõ các segment của tiến trình thì có thể xem lại trong bài học "Khái quát về tiến trình" ở đây

Hình vẽ dưới đây mô tả về một tiến trình đơn luồng (single-thread) và đa luồng (multi-thread):

Hình 1. Tiến trình single-thread và multi-thread

Trong hình vẽ trên, một tiến trình có 4 thread, bao gồm 1 main thread (T0) được tạo ra khi tiến trình chạy hàm main(), và 3 thread lần lượt là T1, T2 và T3 được tạo mới trong hàm main(). Bốn thread sử dụng chung vùng nhớ toàn cục (global memory) nhưng mỗi thread có phân vùng stack riêng của mình, cụ thể như hình vẽ dưới đây:

Hình 2. Tổ chức bộ nhớ của tiến trình có 4 thread (Linux/x86-32)

Các thread trong tiến trình có thể thực thi đồng thời và độc lập với nhau. Nghĩa là nếu một thread bị block do đang chờ I/O thì các thread khác vẫn được lập lịch và thực thi thay vì cả tiến trình bị block.

So sánh Process và Thread

Quay trở lại ví dụ trên, tiến trình server tạo ra các tiến trình con để phục vụ yêu cầu multi-task. Cách này tuy giải quyết được yêu cầu nhưng tồn tại các hạn chế sau đây:

  • Việc chia sẻ dữ liệu giữa các tiến trình khá khó khăn. Vì mỗi tiến trình trong Linux có không gian bộ nhớ riêng biệt nên chúng ta phải sử dụng các phương pháp giao tiếp liên tiến trình (IPC) như shared memory, message queue, socket... để chia sẻ dữ liệu.

  • Tạo ra một tiến trình mới bằng system call fork() khá "tốn kém" về mặt tài nguyên cũng như thời gian vì phải tạo ra các vùng nhớ riêng biệt cho tiến trình con. Điều này khá quan trọng trong các hệ thống embedded có phần cứng bị hạn chế.

Thread có thể giải quyết được 2 vấn đề trên vì có các ưu điểm sau:

  • Chia sẻ dữ liệu giữa các thread trong tiến trình rất đơn giản vì chúng sử có chung không gian bộ nhớ toàn cục. Do vậy, chỉ cần tạo dữ liệu ở trong các vùng nhớ toàn cục này thì các thread đều có thể truy xuất được.
  • Việc tạo ra một thread mới nhanh hơn đáng kể so với việc tạo ra một tiến trình mới vì các thread dùng chung nhiều phần không gian bộ nhớ nên chỉ cần tạo không gian bộ nhớ cho những phần riêng thay vì phải nhân bản toàn bộ các vùng nhớ như khi tạo tiến trình con.

Hiển nhiên thread cũng không phải là chìa khóa vạn năng. Việc sử dụng thread cũng có các nhược điểm sau:

  • Vì các thread dùng chung vùng nhớ toàn cục nên việc lập trình trên các thread "nguy hiểm" hơn trên process. Nếu một thread gây ra lỗi trên vùng nhớ toàn cục thì sẽ kéo theo các thread khác cũng bị lỗi theo.
  • Các thread cùng chia sẻ vùng nhớ toàn cục của một tiến trình (3 GB với hệ thống 32 bit), cụ thể mỗi thread sẽ được cung cấp một vùng nhớ riêng trong tổng thể bộ nhớ của tiến trình. Bộ nhớ của tiến trình tuy là lớn nhưng cũng là một số hữu hạn. Nên một tiến trình cũng bị giới hạn bởi số lượng thread có thể tạo ra hoặc tạo ra các thread cần bộ nhớ lớn.

Cả hai nhược điểm trên không xảy ra trên tiến trình vì mỗi tiến trình có không gian bộ nhớ riêng.

Posix Thread

Quay lại thời điểm sơ khai của thread, khi đó mỗi nhà cung cấp phần cứng triển khai thread và cung cấp các API để lập trình thread của riêng mình. Điều này gây khó khăn cho các developer khi phải học nhiều phiên bản thread và viết 1 chương trình thread chạy đa nền tảng phần cứng. Trước nhu cầu xây dựng một giao diện lập trình thread chung, tiêu chuẩn POSIX Thread (pthread) cung cấp các giao diện lập trình thread trên ngôn ngữ C/C++ đã ra đời.

Pthread data type

Trước khi bắt tay vào khám phá các API của pthread, chúng ta cần lướt qua một số kiểu dữ liệu pthread định nghĩa riêng dưới đây:

Kiểu dữ liệu Mô tả
pthread_t Số định danh của thread (threadID)
pthread_mutex_t Mutex
pthread_mutexattr_t Thuộc tính của mutex
pthread_cond_t Biến điều kiện
pthread_condattr_t Thuộc tính của biến điều kiện
pthread_key_t Khóa cho dữ liệu của thread
pthread_attr_t Thuộc tính của thread

Các bạn đừng lo lắng nếu thấy các kiểu dữ liệu này lạ lẫm, các kiểu dữ liệu cần thiết sẽ được giải thích dần ở các bài sau. Trong bài này, chúng ta chỉ cần quan tâm đến kiểu pthread_t là một số định danh cho thread sẽ được giải thích ở bên dưới.

Tạo thread mới

Để tạo ra một thread mới, ta sử dụng hàm pthread_create() với prototype như sau:

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg);

                                 /*Return 0 nếu thành công, hoặc một số dương error number nếu lỗi*/

Hàm pthread_create() tạo ra một thread mới trong tiến trình, entry point của thread mới này là hàm “start()” với đối số là “arg” (start(arg)). Main thread của tiến trình tiếp tục thực thi với các câu lệnh sau hàm pthread_create() đó. Đối số “arg” được truyền vào có kiểu void, nghĩa là ta có thể truyền bất kỳ kiểu dữ liệu nào vào hàm start(), hoặc truyền vào con trỏ NULL nếu hàm start() không cần đối số. Nếu muốn truyền nhiều đối số vào hàm start(), ta có thể khai báo đối số “arg” dưới dạng một con trỏ trỏ đến một cấu trúc với các đối số là các trường riêng biệt (chúng ta sẽ xét một ví dụ bên dưới để rõ hơn về cách này).

Thread ID

Mỗi thread trong tiến trình có 1 số định danh duy nhất là thread ID. Thread ID trong Posix có kiểu dữ liệu là pthread_t, chính là giá trị pthread_t *thread được gán vào trong hàm pthread_create() ở trên. Ta có thể kiểm tra được thread ID của thread đang chạy bằng hàm pthread_self() với prototype như sau:

include <pthread.h>

pthread_t pthread_self(void);
           /*Trả về thread ID của thread đang gọi*/

Thread ID có thể được dùng khi lập trình viên muốn tác động vào thread đó như join thread (sẽ học dưới đây), kill thread …

Kết thúc thread

Một thread đang thực thi có thể được kết thúc bằng một trong các cách sau:

  • Hàm bắt đầu của thread thực thi câu lệnh return

  • Một hàm bất kỳ trong thread gọi hàm pthread_exit(), chúng ta sẽ nói về hàm này ở dưới đây

  • Thread bị hủy bỏ bằng hàm pthread_cancel()

  • Một thread bất kỳ của tiến trình gọi hàm exit() hoặc thread chính của tiến trình (hàm main()) gọi return. Cả 2 cách này đều có tác dụng kết thúc tiến trình đang chạy và tất nhiên cả các thread của tiến trình đó.

Hàm pthread_exit() có prototype như sau:

#include <pthread.h>

void pthread_exit(void *retval);

Việc gọi hàm pthread_exit() có tác dụng giống với việc gọi return của hàm bắt đầu của thread đó, chỉ khác là pthread_exit() có thể gọi từ bất kỳ hàm nào trong thread còn return bắt buộc phải gọi ở hàm bắt đầu của thread. Đối số retval là giá trị return của hàm. Lưu ý rằng hàm pthread_exit() không return giá trị nào cho hàm gọi nó.

Ví dụ

Sau khi nắm được lý thuyết cơ bản của pthread cùng với 2 hàm pthread_create() và pthread_exit(), ta có thể viết được 1 chương trình sử dụng pthread. Để sử dụng các hàm API của Posix thread, chương trình cần khai báo thư viện pthread.h.

Chương trình ví dụ dưới đây tạo ra 1 thread mới trong hàm main(), hàm bắt đầu của thread là printHello() sẽ in ra màn hình dòng chữ “Hello World! This is entry point of thread!”. Vì hàm bắt đầu này không cần đối số truyền vào nên tham số “void *arg” sẽ truyền vào NULL:

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

static void *printHello(void* argv)
{
        printf("Hello World! This is entry point of thread!\n");
        pthread_exit(NULL);
}

int main (void)
{
        pthread_t threadID;
        int ret;

        ret = pthread_create(&threadID, NULL, printHello, NULL);
        if(ret)
        {
                printf("pthread_create() error number=%d\n", ret);
                return -1;
        }
        pthread_exit(NULL);
}

Lưu chương trình với tên gọi pthread.c và compile chương trình với option “pthread” như sau:

gcc -pthread -o pthread pthread.c

Bây giờ chạy chương trình, bạn sẽ thấy output của chương trình như sau:

Trong chương trình trên, hàm bắt đầu printHello() không truyền đối số. Giả sử bạn cần viết 1 chương trình cần truyền đối số thì sẽ phải xây dựng một struct để lưu các đối số đó và truyền địa chỉ vào đối số “void *arg” trong hàm pthread_create().

Để hiểu rõ hơn, chúng ta xét ví dụ khác: tạo ra 1 thread mới cũng với hàm bắt đầu là printHello() nhưng đối số “thr” có kiểu dữ liệu struct thread_args được truyền vào. Lưu ý trong hàm pthread_create(), đối số của hàm printHello() quy định kiểu void*, nên trong hàm printHello() ta cần khai báo args kiểu void*, sau đó ép về kiểu thread_args sau:

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

typedef struct
{
    char name[16];
    char msg[64];
}thread_args;


static void *printHello(void *args)
{
    thread_args *thr_args = (thread_args*)args;
    printf ("Hello World! I am: %s!\n", thr_args->name);
    printf ("%s!\n", thr_args->msg);

    pthread_exit(NULL);
}

int main (void)
{
    pthread_t threadID;
    int ret;
    long i = 0;
    thread_args thr;

    memset(&thr, 0x0, sizeof(thread_args));
    strncpy(thr.name, "vimentor", sizeof(thr.name));
	
    ret = pthread_create(&threadID, NULL, printHello, (&thr));
    if(ret)
    {
        printf("pthread_create() error number is %d\n", ret);
        return -1; 
    }
    pthread_exit(NULL);
}

Bây giờ compile chương trình và chạy, chúng ta sẽ được output như sau:

Kết luận

Bài học này đã giúp chúng ta thấy được rằng thread có rất nhiều ưu điểm trong lập trình multi-task, tuy nhiên lập trình thread cũng tiềm tàng nhiều nguy cơ thiếu an toàn do các thread sử dụng chung bộ nhớ toàn cục của tiến trình. Bài học cũng giới thiệu về Posix thread vốn được sử dụng rất rộng rãi trong các hệ thống Unix hiện nay. Qua đó chúng ta cũng được hướng dẫn cách lập cơ bản với Posix thread. Bài học sau sẽ giới thiệu thêm các Posix API khác được sử dụng trong lập trình thread trong hệ thống Linux.

Tài liệu tham khảo

The Linux Programming Interface - Michael Kerrisk - Chapter 29

Linux System Programming - Robert Love - Chapter 7

Luyện tập cho bài học này

Khóa học liên quan

** Nếu bạn muốn viết các nội dung đặt biệt thì hãy làm theo hướng dẫn sau

Xem thêm 10 bình luận
Viết blog mới của bạn
Báo lỗi trang
Đang tải