Skip to content

Lập trình ứng dụng real-time trên Linux

Kernel PREEMPT_RT chỉ bảo đảm độ trễ thấp khi ứng dụng cũng được viết đúng cách: đặt chính sách lập lịch, khóa bộ nhớ, tránh các lệnh gọi có thể chặn và tạo chu kỳ bằng timer chính xác. Trang này trình bày phần việc ở phía ứng dụng user space theo thứ tự: chính sách lập lịch và ưu tiên, quyền chạy không cần root, quản lý bộ nhớ, chu kỳ chạy, đồng bộ giữa các thread, gắn thread vào core, một ví dụ hoàn chỉnh, rồi cách kiểm tra. Phần cấu hình kernel và cô lập core nằm ở các trang trong mục Real-time & CPU Isolation của tab Linux Kernel.

Chính sách lập lịch và ưu tiên

Mặc định, thread dùng SCHED_OTHER và scheduler không bảo đảm thời điểm thread được chạy. Thread real-time phải được đặt sang SCHED_FIFO (hoặc SCHED_RR) với mức ưu tiên từ 1 đến 99, bằng một trong các cách sau:

  • sched_setscheduler() hoặc sched_setattr() cho thread hiện tại.
  • pthread_attr_setschedpolicy() và pthread_attr_setschedparam() khi tạo thread.
  • Lệnh chrt khi khởi chạy, ví dụ chrt -f 80 ./app, hoặc chrt -f -p 80 <pid> cho tiến trình đang chạy.

Khi đặt chính sách qua pthread_attr_t, cần gọi thêm pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED). Nếu không, thread mới kế thừa chính sách của thread tạo ra nó và các thuộc tính vừa đặt bị bỏ qua.

Mức ưu tiên phải được sắp xếp cùng với các thread của kernel. Ví dụ, một thread ứng dụng ưu tiên cao hơn thread ngắt của thiết bị mà nó đang chờ sẽ chặn chính thread ngắt đó. Xem bảng mức ưu tiên gợi ý và quan hệ với thread ngắt ở PREEMPT_RT.

Thread SCHED_FIFO không có lát thời gian, nên một vòng lặp bận ở mức ưu tiên cao có thể chiếm hết một core. Kernel giới hạn tổng thời gian real-time bằng sched_rt_runtime_us trên mỗi sched_rt_period_us (mặc định 950000 trên 1000000 µs), xem Real-Time Group Scheduling. Giới hạn này để lại một phần CPU cho các task thường.

Chạy không cần root

Đặt chính sách real-time và khóa bộ nhớ đều cần quyền. Thay vì chạy ứng dụng bằng root, cấp đúng quyền cần thiết:

Nhu cầu Capability Giới hạn tài nguyên (rlimit)
Đặt SCHED_FIFO/SCHED_RR CAP_SYS_NICE RLIMIT_RTPRIO
Khóa bộ nhớ bằng mlock/mlockall CAP_IPC_LOCK RLIMIT_MEMLOCK

Với dịch vụ chạy dưới systemd, dùng AmbientCapabilities= hoặc các tùy chọn LimitRTPRIO= và LimitMEMLOCK=. Với tiến trình khởi chạy từ phiên đăng nhập, đặt rlimit trong /etc/security/limits.conf.

Todo

Chưa kiểm chứng các tên tùy chọn systemd và cú pháp limits.conf trên image của sản phẩm; cần thử trên board với người dùng không phải root.

Quản lý bộ nhớ

Page fault là nguồn gây trễ phổ biến nhất của ứng dụng real-time: lần đầu chạm vào một trang chưa được cấp, kernel phải cấp trang (minor fault) hoặc đọc từ đĩa (major fault) ngay trong đường chạy tới hạn. Có ba biện pháp, thường dùng cùng nhau.

  • Khóa toàn bộ bộ nhớ vào RAM. Gọi mlockall(MCL_CURRENT | MCL_FUTURE) trong giai đoạn khởi tạo, trước khi vào vòng lặp real-time. Cờ MCL_FUTURE khóa cả các vùng được cấp về sau. Khóa quá nhiều bộ nhớ có thể gây hết bộ nhớ (OOM), nên cần biết trước mức tiêu thụ của ứng dụng.
  • Chạm trước stack. Stack của thread real-time chỉ được cấp khi dùng tới. Khai báo một mảng đủ lớn trên stack và ghi vào nó ngay khi thread bắt đầu, để các trang stack được cấp và khóa trước khi vào chu kỳ.
  • Cấp phát trước và không cấp phát trong đường chạy tới hạn. malloc và free có thể gọi brk hoặc mmap và gây page fault hoặc tranh chấp khóa. Cấp phát toàn bộ bộ đệm, hàng đợi và pool trong giai đoạn khởi tạo rồi tái sử dụng.

Todo

Cần kiểm chứng và ghi lại các tham số mallopt (tắt trim và mmap của glibc malloc) phù hợp với phiên bản glibc của sản phẩm trước khi đưa vào trang.

Chu kỳ chạy

Thread real-time định kỳ nên ngủ tới một thời điểm tuyệt đối thay vì ngủ một khoảng tương đối. Với clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...), thời điểm đánh thức tiếp theo được tính từ thời điểm đánh thức trước, nên thời gian xử lý và độ trễ đánh thức của một chu kỳ không cộng dồn thành sai lệch chu kỳ. CLOCK_MONOTONIC không bị ảnh hưởng khi đồng hồ hệ thống được chỉnh, khác với CLOCK_REALTIME.

Hệ thống cần hỗ trợ timer độ phân giải cao (high-resolution timer). Có thể kiểm tra bằng clock_getres(CLOCK_MONOTONIC, ...): độ phân giải trả về phải nhỏ hơn nhiều so với chu kỳ của ứng dụng.

Đồng bộ giữa các thread

Khi thread ưu tiên thấp giữ một mutex mà thread ưu tiên cao cần, thread ưu tiên cao bị chặn lâu hơn dự kiến. Hiện tượng này gọi là đảo ngược ưu tiên (priority inversion), xem thêm Design Patterns. Với POSIX mutex, bật kế thừa ưu tiên (priority inheritance) bằng thuộc tính giao thức:

pthread_mutexattr_t ma;
pthread_mutex_t lock;

pthread_mutexattr_init(&ma);
pthread_mutexattr_setprotocol(&ma, PTHREAD_PRIO_INHERIT);
pthread_mutex_init(&lock, &ma);

Ngoài ra, đường chạy tới hạn không nên gọi các hàm có thể chặn không giới hạn: I/O tới tệp hoặc console (printf), sleep kiểu tương đối, hoặc thư viện có khóa nội bộ không kế thừa ưu tiên. Dữ liệu cần ghi log nên được đẩy qua hàng đợi không khóa tới một thread thường ghi ra ngoài.

Gắn thread vào core

Thread real-time nên được gắn vào các core đã cô lập, bằng pthread_attr_setaffinity_np() khi tạo thread hoặc sched_setaffinity() trong thread. Việc cô lập core ở mức kernel (isolcpus, nohz_full, IRQ affinity) được trình bày ở isolcpus, nohz_full và IRQ affinity. Thread thường của cùng ứng dụng gắn vào các core housekeeping.

Ví dụ hoàn chỉnh

Ví dụ dưới đây tạo một thread SCHED_FIFO ưu tiên 80, gắn vào core 1, chạy chu kỳ 1 ms trong 5000 chu kỳ. Chương trình đã được biên dịch bằng gcc -Wall -Wextra -pthread và chạy được trên môi trường thử nghiệm.

#define _GNU_SOURCE
#include <errno.h>
#include <limits.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <time.h>

#define PERIOD_NS 1000000L   /* chu kỳ 1 ms */
#define STACK_PREFAULT (256 * 1024)
#define NSEC_PER_SEC 1000000000L

static void prefault_stack(void)
{
    volatile unsigned char buf[STACK_PREFAULT];
    memset((void *)buf, 0, sizeof(buf));
}

static void *rt_loop(void *arg)
{
    struct timespec next;
    int i;
    (void)arg;

    prefault_stack();
    clock_gettime(CLOCK_MONOTONIC, &next);
    for (i = 0; i < 5000; i++) {
        next.tv_nsec += PERIOD_NS;
        if (next.tv_nsec >= NSEC_PER_SEC) {
            next.tv_nsec -= NSEC_PER_SEC;
            next.tv_sec++;
        }
        clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
        /* Xử lý theo chu kỳ đặt ở đây */
    }
    return NULL;
}

int main(void)
{
    pthread_attr_t attr;
    pthread_t tid;
    struct sched_param sp = { .sched_priority = 80 };
    cpu_set_t set;
    int ret;

    if (mlockall(MCL_CURRENT | MCL_FUTURE) == -1)
        fprintf(stderr, "mlockall: %s\n", strerror(errno));

    pthread_attr_init(&attr);
    pthread_attr_setstacksize(&attr, PTHREAD_STACK_MIN + STACK_PREFAULT * 2);
    pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED);
    pthread_attr_setschedpolicy(&attr, SCHED_FIFO);
    pthread_attr_setschedparam(&attr, &sp);
    CPU_ZERO(&set);
    CPU_SET(1, &set);
    pthread_attr_setaffinity_np(&attr, sizeof(set), &set);

    ret = pthread_create(&tid, &attr, rt_loop, NULL);
    if (ret) {
        fprintf(stderr, "pthread_create: %s\n", strerror(ret));
        return 1;
    }
    pthread_join(tid, NULL);
    return 0;
}

Một số điểm cần lưu ý trong mã:

  • mlockall() được gọi trước khi tạo thread, và hàm prefault_stack() chạy đầu thread, trước khi vào vòng lặp chu kỳ.
  • Thời điểm tiếp theo được cộng dồn vào biến next thay vì đọc lại đồng hồ mỗi chu kỳ.
  • Ví dụ không kiểm tra lỗi đầy đủ của pthread_attr_*; mã sản phẩm cần kiểm tra mọi giá trị trả về.

Kiểm tra ứng dụng

Hai việc cần kiểm tra sau khi viết xong: thread có thực sự chạy ở chính sách mong muốn, và đường chạy tới hạn không còn page fault.

Cần kiểm tra Cách làm
Chính sách và ưu tiên chrt -p <pid>, hoặc ps -eLo pid,tid,cls,rtprio,psr,comm để xem từng thread
Page fault Đọc cột minflt và majflt trong /proc/<pid>/stat, hoặc getrusage() (ru_minflt, ru_majflt) trước và sau vòng chạy; hai giá trị không được tăng trong đường chạy tới hạn
Độ trễ lập lịch tổng thể cyclictest, xem Real-time Linux và Embedded overview
Nguồn gây trễ trong kernel Latency tracer của ftrace

Liên quan

Tài liệu tham khảo