MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言多线程调度的优化方案

2021-06-162.4k 阅读

一、Linux C 语言多线程调度基础

在 Linux 环境下使用 C 语言进行多线程编程,通常会用到 POSIX 线程库(pthread)。多线程调度是指操作系统如何安排各个线程在 CPU 上执行的过程。

(一)线程创建与基本调度

  1. 线程创建 使用 pthread_create 函数来创建线程,其原型如下:
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

其中,thread 是指向新创建线程 ID 的指针;attr 用于设置线程属性,若为 NULL,则使用默认属性;start_routine 是线程执行的函数;arg 是传递给该函数的参数。

示例代码:

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

void* thread_function(void* arg) {
    printf("This is a thread, argument: %d\n", *((int*)arg));
    return NULL;
}

int main() {
    pthread_t thread;
    int arg = 42;
    int result = pthread_create(&thread, NULL, thread_function, &arg);
    if (result != 0) {
        printf("Error creating thread\n");
        return 1;
    }
    pthread_join(thread, NULL);
    printf("Main thread continues\n");
    return 0;
}

在上述代码中,创建了一个新线程,主线程等待新线程执行完毕(通过 pthread_join)。默认情况下,操作系统会根据其调度算法来决定线程何时执行。

  1. 基本调度算法 Linux 内核使用的调度算法主要有 CFS(Completely Fair Scheduler)等。CFS 旨在为每个进程(线程)提供公平的 CPU 时间片。对于线程来说,它们会被视为轻量级进程参与调度。每个线程都有一个调度实体,CFS 通过计算每个调度实体的虚拟运行时间(vruntime)来决定哪个线程应该获得 CPU 执行权。虚拟运行时间小的线程会优先执行,这样可以保证每个线程都能相对公平地获得 CPU 时间。

二、多线程调度面临的问题

(一)资源竞争

  1. 共享资源竞争 当多个线程访问共享资源(如全局变量、文件描述符等)时,会发生资源竞争。例如:
#include <stdio.h>
#include <pthread.h>

int shared_variable = 0;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        shared_variable++;
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    printf("Shared variable value: %d\n", shared_variable);
    return 0;
}

在上述代码中,两个线程同时对 shared_variable 进行自增操作。由于现代 CPU 通常采用流水线、缓存等技术,以及操作系统的线程调度机制,可能会导致其中一个线程读取 shared_variable 的值后,还未完成自增操作,另一个线程又读取了相同的值,从而造成结果不准确。这种现象被称为竞态条件(race condition)。

  1. 锁争用 为了解决资源竞争问题,通常会使用锁机制(如互斥锁 pthread_mutex_t)。但过多地使用锁会导致锁争用问题。例如:
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_variable = 0;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&mutex);
        shared_variable++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    printf("Shared variable value: %d\n", shared_variable);
    pthread_mutex_destroy(&mutex);
    return 0;
}

在这个改进版本中,通过互斥锁保证了 shared_variable 的原子操作。然而,如果有大量线程频繁地获取和释放锁,会导致线程在锁上等待的时间增加,降低整体性能。这就是锁争用问题。

(二)线程上下文切换开销

  1. 上下文切换原理 当操作系统决定切换线程时,需要保存当前线程的上下文(包括 CPU 寄存器的值、栈指针等),并恢复下一个要执行线程的上下文。这个过程涉及到内存访问等操作,会带来一定的开销。例如,在一个多核 CPU 系统中,线程 A 在核心 1 上执行,由于调度算法,线程 A 被暂停,线程 B 被调度到核心 1 上执行。此时,操作系统需要将线程 A 的寄存器值等上下文信息保存到内存中,然后从内存中读取线程 B 的上下文信息并加载到 CPU 寄存器中,这一系列操作都需要消耗时间。

  2. 频繁上下文切换的影响 如果线程数量过多,或者线程执行时间过短,就会导致频繁的上下文切换。这不仅会消耗 CPU 时间,还会影响缓存命中率。因为每次上下文切换后,新执行的线程可能会访问不同的内存区域,导致之前缓存的数据失效,从而增加内存访问时间,降低系统整体性能。

三、多线程调度的优化方案

(一)减少资源竞争

  1. 优化锁的使用
    • 细粒度锁:避免使用粗粒度锁,即对整个共享资源使用一把锁。可以将共享资源划分为多个部分,每个部分使用单独的锁。例如,对于一个大型数组,如果多个线程可能会访问不同的区域,可以为数组的不同部分设置不同的锁。
#include <stdio.h>
#include <pthread.h>

#define ARRAY_SIZE 1000
int array[ARRAY_SIZE];
pthread_mutex_t mutexes[ARRAY_SIZE];

void* update_array(void* arg) {
    int index = *((int*)arg);
    pthread_mutex_lock(&mutexes[index]);
    array[index]++;
    pthread_mutex_unlock(&mutexes[index]);
    return NULL;
}

int main() {
    pthread_t threads[ARRAY_SIZE];
    for (int i = 0; i < ARRAY_SIZE; i++) {
        pthread_mutex_init(&mutexes[i], NULL);
    }
    for (int i = 0; i < ARRAY_SIZE; i++) {
        int* arg = &i;
        pthread_create(&threads[i], NULL, update_array, arg);
    }
    for (int i = 0; i < ARRAY_SIZE; i++) {
        pthread_join(threads[i], NULL);
    }
    for (int i = 0; i < ARRAY_SIZE; i++) {
        pthread_mutex_destroy(&mutexes[i]);
    }
    return 0;
}

在上述代码中,为数组的每个元素设置了单独的锁,这样不同线程在更新不同元素时不会相互等待,减少了锁争用。 - 读写锁:如果共享资源读操作频繁,写操作较少,可以使用读写锁(pthread_rwlock_t)。读写锁允许多个线程同时进行读操作,但在写操作时会独占资源。

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

int shared_data = 0;
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

void* read_data(void* arg) {
    pthread_rwlock_rdlock(&rwlock);
    printf("Read data: %d\n", shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void* write_data(void* arg) {
    pthread_rwlock_wrlock(&rwlock);
    shared_data++;
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    pthread_t read_thread1, read_thread2, write_thread;
    pthread_create(&read_thread1, NULL, read_data, NULL);
    pthread_create(&read_thread2, NULL, read_data, NULL);
    pthread_create(&write_thread, NULL, write_data, NULL);
    pthread_join(read_thread1, NULL);
    pthread_join(read_thread2, NULL);
    pthread_join(write_thread, NULL);
    pthread_rwlock_destroy(&rwlock);
    return 0;
}

在这个示例中,读线程可以同时获取读锁进行读操作,而写线程需要获取写锁,这样可以提高读操作的并发性能。

  1. 无锁数据结构
    • 无锁队列:使用无锁数据结构可以避免锁的争用。例如,使用 std::atomic 等原子操作实现无锁队列。在 C 语言中,可以借助 GCC 的原子操作扩展来实现类似功能。下面是一个简单的基于 __sync 原子操作的无锁队列示例:
#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>

#define QUEUE_SIZE 10

typedef struct {
    int data[QUEUE_SIZE];
    atomic_int head;
    atomic_int tail;
} lock_free_queue;

void init_queue(lock_free_queue* q) {
    atomic_store(&q->head, 0);
    atomic_store(&q->tail, 0);
}

int enqueue(lock_free_queue* q, int value) {
    int tail = atomic_load(&q->tail);
    int next_tail = (tail + 1) % QUEUE_SIZE;
    if (next_tail == atomic_load(&q->head)) {
        return 0; // Queue is full
    }
    q->data[tail] = value;
    atomic_store(&q->tail, next_tail);
    return 1;
}

int dequeue(lock_free_queue* q, int* value) {
    int head = atomic_load(&q->head);
    if (head == atomic_load(&q->tail)) {
        return 0; // Queue is empty
    }
    *value = q->data[head];
    atomic_store(&q->head, (head + 1) % QUEUE_SIZE);
    return 1;
}

void* producer(void* arg) {
    lock_free_queue* q = (lock_free_queue*)arg;
    for (int i = 0; i < 20; i++) {
        enqueue(q, i);
    }
    return NULL;
}

void* consumer(void* arg) {
    lock_free_queue* q = (lock_free_queue*)arg;
    int value;
    while (1) {
        if (dequeue(q, &value)) {
            printf("Consumed: %d\n", value);
        } else {
            break;
        }
    }
    return NULL;
}

int main() {
    lock_free_queue queue;
    init_queue(&queue);
    pthread_t producer_thread, consumer_thread;
    pthread_create(&producer_thread, NULL, producer, &queue);
    pthread_create(&consumer_thread, NULL, consumer, &queue);
    pthread_join(producer_thread, NULL);
    pthread_join(consumer_thread, NULL);
    return 0;
}

在这个无锁队列示例中,通过 atomic_int 类型和 atomic_storeatomic_load 等原子操作,实现了线程安全的队列操作,避免了锁的使用。

(二)优化线程上下文切换

  1. 合理设置线程数量 根据系统的 CPU 核心数和任务类型来合理设置线程数量。一般来说,对于 CPU 密集型任务,线程数量可以设置为 CPU 核心数,这样可以充分利用 CPU 资源,减少线程上下文切换。例如,在一个 4 核心的 CPU 系统中,对于纯计算的任务,如果创建 10 个线程,反而会因为频繁的上下文切换降低性能。可以通过 sysconf(_SC_NPROCESSORS_ONLN) 函数获取系统的 CPU 核心数。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* cpu_intensive_task(void* arg) {
    for (volatile int i = 0; i < 1000000000; i++);
    return NULL;
}

int main() {
    int num_cores = sysconf(_SC_NPROCESSORS_ONLN);
    pthread_t threads[num_cores];
    for (int i = 0; i < num_cores; i++) {
        pthread_create(&threads[i], NULL, cpu_intensive_task, NULL);
    }
    for (int i = 0; i < num_cores; i++) {
        pthread_join(threads[i], NULL);
    }
    return 0;
}

在上述代码中,根据 CPU 核心数创建线程,避免了过多线程导致的上下文切换开销。

  1. 线程亲和性设置 线程亲和性是指将线程绑定到特定的 CPU 核心上执行。这样可以减少线程在不同核心之间切换带来的缓存失效等问题。在 Linux 下,可以使用 sched_setaffinity 函数来设置线程亲和性。
#include <stdio.h>
#include <pthread.h>
#include <sched.h>

void* task(void* arg) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(0, &cpuset);
    if (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) == -1) {
        perror("sched_setaffinity");
    }
    for (volatile int i = 0; i < 1000000000; i++);
    return NULL;
}

int main() {
    pthread_t thread;
    pthread_create(&thread, NULL, task, NULL);
    pthread_join(thread, NULL);
    return 0;
}

在上述代码中,将线程绑定到 CPU 核心 0 上执行,这样可以提高缓存命中率,减少上下文切换开销。

(三)利用线程调度策略

  1. SCHED_FIFO 和 SCHED_RR 调度策略 除了默认的 CFS 调度策略,Linux 还支持 SCHED_FIFO(先进先出)和 SCHED_RR(时间片轮转)调度策略。SCHED_FIFO 调度策略会让优先级高的线程一直运行,直到它主动放弃 CPU 或者被更高优先级的线程抢占。SCHED_RR 调度策略与 SCHED_FIFO 类似,但每个线程会被分配一个时间片,时间片用完后,该线程会被放到就绪队列末尾。 可以通过 pthread_setschedparam 函数来设置线程的调度策略和优先级。
#include <stdio.h>
#include <pthread.h>
#include <sched.h>

void* high_priority_task(void* arg) {
    struct sched_param param;
    param.sched_priority = sched_get_priority_max(SCHED_RR);
    if (pthread_setschedparam(pthread_self(), SCHED_RR, &param) == -1) {
        perror("pthread_setschedparam");
    }
    for (volatile int i = 0; i < 1000000000; i++);
    return NULL;
}

void* low_priority_task(void* arg) {
    struct sched_param param;
    param.sched_priority = sched_get_priority_min(SCHED_RR);
    if (pthread_setschedparam(pthread_self(), SCHED_RR, &param) == -1) {
        perror("pthread_setschedparam");
    }
    for (volatile int i = 0; i < 1000000000; i++);
    return NULL;
}

int main() {
    pthread_t high_thread, low_thread;
    pthread_create(&high_thread, NULL, high_priority_task, NULL);
    pthread_create(&low_thread, NULL, low_priority_task, NULL);
    pthread_join(high_thread, NULL);
    pthread_join(low_thread, NULL);
    return 0;
}

在上述代码中,分别设置了两个线程的调度策略为 SCHED_RR,并设置了不同的优先级。这样可以根据任务的重要性和紧急程度来分配 CPU 资源,优化多线程调度。

  1. 实时调度策略的应用场景 实时调度策略(如 SCHED_FIFOSCHED_RR)适用于对时间敏感的任务,例如多媒体播放、工业控制等场景。在这些场景中,任务需要在特定的时间内完成,通过设置合适的调度策略和优先级,可以保证关键任务的及时执行,避免因调度问题导致的性能问题或系统故障。

四、性能评估与调优实践

(一)性能评估工具

  1. 使用 perf 工具 perf 是 Linux 下强大的性能分析工具,可以用来分析 CPU 使用率、缓存命中率、上下文切换次数等性能指标。例如,要分析一个多线程程序的性能,可以使用以下命令:
perf record./your_program

运行完程序后,可以使用 perf report 命令查看详细的性能报告,其中会包含各个函数的 CPU 占用时间、缓存命中率等信息。通过分析这些信息,可以找出性能瓶颈所在。

  1. 使用 gprof 工具 gprof 是 GNU 提供的性能分析工具,可以生成程序中函数调用关系和每个函数执行时间的统计信息。首先需要在编译时加上 -pg 选项:
gcc -pg -o your_program your_program.c -lpthread

运行程序后,会生成 gmon.out 文件,使用 gprof 命令分析该文件:

gprof your_program gmon.out

gprof 会输出函数的调用次数、执行时间等信息,帮助开发者定位性能问题。

(二)调优实践案例

  1. 案例背景 假设有一个多线程程序,用于处理大量的图像数据。程序中有多个线程负责图像的读取、处理和存储。在初始版本中,程序性能较低,处理速度慢。

  2. 性能分析 使用 perf 工具分析发现,上下文切换次数过多,并且在图像数据处理函数中存在大量的锁争用。进一步使用 gprof 工具分析,确定了具体的锁争用函数和频繁上下文切换的线程。

  3. 优化措施

    • 减少上下文切换:根据 CPU 核心数,合理调整线程数量,将线程数量减少到与 CPU 核心数相同。同时,为每个线程设置线程亲和性,将不同功能的线程绑定到不同的 CPU 核心上。
    • 优化锁的使用:对图像数据处理部分,将原来的粗粒度锁改为细粒度锁。例如,将图像数据按区域划分,每个区域使用单独的锁。
  4. 优化效果 经过优化后,再次使用 perfgprof 工具分析,发现上下文切换次数大幅减少,锁争用情况也得到明显改善。程序的处理速度提高了 30%以上,性能得到了显著提升。

通过以上优化方案和性能评估方法,可以有效地优化 Linux C 语言多线程调度,提高程序的性能和稳定性。在实际开发中,需要根据具体的应用场景和需求,灵活运用这些方法,不断进行优化和调优。