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

Linux C语言多线程服务器中的资源竞争与解决方案

2023-03-016.9k 阅读

多线程服务器中的资源竞争问题

在 Linux C 语言开发的多线程服务器环境下,资源竞争是一个常见且棘手的问题。多线程编程的优势在于能够充分利用多核 CPU 的性能,提高服务器的并发处理能力。然而,当多个线程同时访问和修改共享资源时,就可能出现资源竞争的情况,导致程序出现不可预测的行为。

共享资源的概念

共享资源是指多个线程都可以访问和修改的资源,在多线程服务器中,这些资源可能包括:

  1. 内存数据结构:如共享的链表、哈希表等,用于存储客户端连接信息、服务器状态等数据。例如,一个链表用于记录所有已连接的客户端套接字描述符,多个线程可能需要同时对这个链表进行添加(新客户端连接)和删除(客户端断开连接)操作。
  2. 文件描述符:服务器可能需要多个线程同时读写同一个日志文件,记录服务器的运行状态、客户端请求等信息。这时,文件描述符就成为了共享资源。
  3. 网络套接字:在一些情况下,多个线程可能需要对同一个监听套接字进行操作,比如在处理新连接时,可能有多个线程竞争接受新连接的机会。

资源竞争产生的原因

  1. 并发访问:现代操作系统采用分时复用的方式调度线程执行,多个线程会在不同的时间片内交替执行。当多个线程同时访问共享资源时,如果没有合适的同步机制,就可能出现一个线程在修改共享资源的过程中,被另一个线程打断,导致数据不一致。例如,假设有两个线程 thread1thread2 同时对一个共享的计数器 counter 进行加 1 操作。thread1 读取 counter 的值为 10,正要进行加 1 操作时,时间片用完,操作系统调度 thread2 执行。thread2 也读取 counter 的值为 10,然后进行加 1 操作并将结果 11 写回。此时 thread1 恢复执行,它继续之前的操作,将 10 加 1 后写回,最终 counter 的值为 11,而不是预期的 12。
  2. 异步操作:线程可能会以异步的方式响应外部事件,如网络 I/O 事件。例如,在一个基于事件驱动的多线程服务器中,不同的线程可能会同时响应不同客户端的请求,这些请求可能涉及对共享资源的操作。如果没有正确处理,就容易引发资源竞争。

资源竞争的影响

  1. 数据不一致:这是资源竞争最直接的后果。共享资源的数据可能会被错误地修改,导致程序运行结果不符合预期。比如在一个银行转账的多线程模拟程序中,一个线程负责从账户 A 扣款,另一个线程负责向账户 B 存款,如果在这两个操作之间发生资源竞争,可能会导致账户 A 扣款成功,但账户 B 未收到相应款项,造成数据不一致。
  2. 程序崩溃:在严重的情况下,资源竞争可能导致程序崩溃。例如,多个线程同时对一个动态分配的内存块进行释放操作,或者访问已经被其他线程释放的内存,就会引发段错误,导致程序终止。
  3. 性能下降:即使资源竞争没有导致程序出现明显的错误,也可能会因为数据不一致而导致程序在后续的处理中进行不必要的重复计算或错误的决策,从而降低程序的整体性能。

解决资源竞争的方法

为了解决多线程服务器中的资源竞争问题,我们可以采用多种同步机制,以下是一些常见的方法:

互斥锁(Mutex)

  1. 原理:互斥锁(Mutex,即 Mutual Exclusion 的缩写)是一种二元信号量,它只有两种状态:锁定(locked)和解锁(unlocked)。当一个线程获取了互斥锁(将其状态设为锁定),其他线程就无法再获取该互斥锁,直到该线程释放互斥锁(将其状态设为解锁)。这样就保证了在同一时刻只有一个线程能够访问被互斥锁保护的共享资源。
  2. 使用方法:在 Linux C 语言中,使用 pthread_mutex_t 类型来表示互斥锁,相关函数如下:
    • 初始化互斥锁
      #include <pthread.h>
      pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
      // 或者使用函数初始化
      int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
      
      其中,PTHREAD_MUTEX_INITIALIZER 是一个宏,用于静态初始化互斥锁。如果需要动态初始化,可以使用 pthread_mutex_init 函数,attr 参数可以为 NULL,表示使用默认属性。
    • 获取互斥锁
      int pthread_mutex_lock(pthread_mutex_t *mutex);
      
      该函数会阻塞调用线程,直到成功获取互斥锁。如果互斥锁已经被其他线程锁定,调用线程会进入等待状态。
    • 释放互斥锁
      int pthread_mutex_unlock(pthread_mutex_t *mutex);
      
      释放互斥锁,允许其他等待的线程获取该互斥锁。
  3. 代码示例
    #include <stdio.h>
    #include <pthread.h>
    
    int counter = 0;
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
    void *increment(void *arg) {
        for (int i = 0; i < 1000000; i++) {
            pthread_mutex_lock(&mutex);
            counter++;
            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("Final counter value: %d\n", counter);
    
        pthread_mutex_destroy(&mutex);
        return 0;
    }
    
    在这个示例中,counter 是共享资源,通过互斥锁 mutex 来保护。两个线程 thread1thread2 在对 counter 进行加 1 操作前,先获取互斥锁,操作完成后释放互斥锁,从而避免了资源竞争。

读写锁(Read - Write Lock)

  1. 原理:读写锁允许在同一时刻有多个线程同时进行读操作,但只允许一个线程进行写操作。当有线程正在进行写操作时,其他线程无论是读还是写都必须等待。这种机制适用于读操作远远多于写操作的场景,因为读操作不会修改共享资源,所以多个读操作可以并发执行,提高了程序的并发性能。
  2. 使用方法:在 Linux C 语言中,使用 pthread_rwlock_t 类型来表示读写锁,相关函数如下:
    • 初始化读写锁
      #include <pthread.h>
      pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
      // 或者使用函数初始化
      int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
      
    • 获取读锁
      int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
      
      该函数会阻塞调用线程,直到成功获取读锁。如果当前有线程持有写锁,调用线程会进入等待状态。
    • 获取写锁
      int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
      
      该函数会阻塞调用线程,直到成功获取写锁。如果当前有线程持有读锁或写锁,调用线程会进入等待状态。
    • 释放锁
      int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
      
      释放读锁或写锁,根据当前锁的状态,允许其他等待的线程获取相应的锁。
  3. 代码示例
    #include <stdio.h>
    #include <pthread.h>
    
    int data = 0;
    pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
    
    void *reader(void *arg) {
        for (int i = 0; i < 5; i++) {
            pthread_rwlock_rdlock(&rwlock);
            printf("Reader %ld reads data: %d\n", (long)pthread_self(), data);
            pthread_rwlock_unlock(&rwlock);
        }
        return NULL;
    }
    
    void *writer(void *arg) {
        for (int i = 0; i < 5; i++) {
            pthread_rwlock_wrlock(&rwlock);
            data++;
            printf("Writer %ld writes data: %d\n", (long)pthread_self(), data);
            pthread_rwlock_unlock(&rwlock);
        }
        return NULL;
    }
    
    int main() {
        pthread_t readers[3], writerThread;
    
        for (int i = 0; i < 3; i++) {
            pthread_create(&readers[i], NULL, reader, NULL);
        }
        pthread_create(&writerThread, NULL, writer, NULL);
    
        for (int i = 0; i < 3; i++) {
            pthread_join(readers[i], NULL);
        }
        pthread_join(writerThread, NULL);
    
        pthread_rwlock_destroy(&rwlock);
        return 0;
    }
    
    在这个示例中,data 是共享资源,通过读写锁 rwlock 来保护。多个读线程可以同时读取 data,而写线程在写入 data 时会独占资源,确保数据的一致性。

条件变量(Condition Variable)

  1. 原理:条件变量用于线程间的同步,它通常与互斥锁配合使用。条件变量允许一个线程等待某个条件满足,当另一个线程改变了共享资源的状态,使得条件满足时,它可以唤醒等待在条件变量上的线程。
  2. 使用方法:在 Linux C 语言中,使用 pthread_cond_t 类型来表示条件变量,相关函数如下:
    • 初始化条件变量
      #include <pthread.h>
      pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
      // 或者使用函数初始化
      int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
      
    • 等待条件变量
      int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
      
      该函数会释放传入的互斥锁,并阻塞调用线程,直到条件变量被唤醒。当线程被唤醒后,它会重新获取互斥锁。
    • 唤醒等待的线程
      int pthread_cond_signal(pthread_cond_t *cond);
      // 唤醒所有等待的线程
      int pthread_cond_broadcast(pthread_cond_t *cond);
      
      pthread_cond_signal 函数唤醒一个等待在条件变量上的线程,pthread_cond_broadcast 函数唤醒所有等待在条件变量上的线程。
  3. 代码示例
    #include <stdio.h>
    #include <pthread.h>
    
    int ready = 0;
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
    
    void *waiter(void *arg) {
        pthread_mutex_lock(&mutex);
        while (!ready) {
            printf("Waiter is waiting...\n");
            pthread_cond_wait(&cond, &mutex);
        }
        printf("Waiter got the signal. Ready is %d\n", ready);
        pthread_mutex_unlock(&mutex);
        return NULL;
    }
    
    void *signalSender(void *arg) {
        pthread_mutex_lock(&mutex);
        ready = 1;
        printf("Signal sender sets ready to 1 and signals.\n");
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&mutex);
        return NULL;
    }
    
    int main() {
        pthread_t waiterThread, signalThread;
    
        pthread_create(&waiterThread, NULL, waiter, NULL);
        pthread_create(&signalThread, NULL, signalSender, NULL);
    
        pthread_join(waiterThread, NULL);
        pthread_join(signalThread, NULL);
    
        pthread_mutex_destroy(&mutex);
        pthread_cond_destroy(&cond);
        return 0;
    }
    
    在这个示例中,waiter 线程等待 ready 条件满足,signalSender 线程改变 ready 的值并发送信号,唤醒 waiter 线程,通过条件变量实现了线程间的同步。

死锁问题及避免

  1. 死锁的概念:死锁是指两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的一种状态。例如,线程 A 持有资源 R1 并等待获取资源 R2,而线程 B 持有资源 R2 并等待获取资源 R1,此时就发生了死锁。
  2. 死锁产生的必要条件
    • 互斥条件:资源在同一时刻只能被一个线程使用。这是资源竞争的基础条件,也是死锁产生的必要条件之一。
    • 占有并等待条件:一个线程在持有至少一个资源的同时,又等待获取其他线程持有的资源。
    • 不可剥夺条件:资源只能由持有它的线程主动释放,其他线程不能强行剥夺。
    • 循环等待条件:存在一个线程 - 资源的循环等待链,链中的每个线程都在等待下一个线程持有的资源。
  3. 避免死锁的方法
    • 破坏死锁产生的条件
      • 破坏占有并等待条件:可以让线程在开始执行时一次性获取所有需要的资源,而不是逐步获取。例如,在一个需要同时访问数据库连接和文件描述符的多线程应用中,线程在启动时就获取这两个资源,避免在持有一个资源的情况下等待另一个资源。
      • 破坏不可剥夺条件:在某些情况下,可以允许操作系统或程序本身剥夺线程持有的资源。例如,当检测到死锁时,强制终止某个线程,释放它持有的资源,以打破死锁。
      • 破坏循环等待条件:对资源进行排序,要求线程按照固定的顺序获取资源。例如,假设有资源 R1、R2 和 R3,规定所有线程都必须先获取 R1,再获取 R2,最后获取 R3,这样就可以避免循环等待的情况。
    • 死锁检测与恢复:可以定期检测系统中是否存在死锁。一种常见的方法是使用资源分配图算法,如银行家算法的变体,来检测死锁。当检测到死锁时,可以选择终止部分线程,释放它们持有的资源,以恢复系统的正常运行。

无锁数据结构

  1. 原理:无锁数据结构通过使用特殊的原子操作和算法,在不需要锁的情况下实现多线程安全的数据访问。原子操作是指在执行过程中不会被其他线程打断的操作,例如 atomic 库提供的原子变量操作。无锁数据结构利用这些原子操作来实现数据的并发访问,避免了锁带来的性能开销和死锁问题。
  2. 常见的无锁数据结构
    • 无锁队列:无锁队列通常使用链表来实现,通过原子操作来管理链表的节点插入和删除。例如,使用 std::atomic<Node*> 来表示链表的头节点和尾节点,在插入和删除节点时使用原子的比较并交换(Compare - And - Swap,CAS)操作,确保操作的原子性和线程安全性。
    • 无锁哈希表:无锁哈希表可以通过将哈希表的每个桶(bucket)独立管理,使用原子操作来处理桶内的数据插入、查找和删除。例如,在每个桶内使用无锁链表或其他无锁数据结构来存储数据,通过原子操作来维护桶内数据的一致性。
  3. 代码示例(简单的无锁计数器)
    #include <stdio.h>
    #include <stdatomic.h>
    
    atomic_int counter = ATOMIC_VAR_INIT(0);
    
    void increment() {
        atomic_fetch_add(&counter, 1);
    }
    
    int main() {
        // 模拟多线程环境下的操作
        for (int i = 0; i < 1000000; i++) {
            increment();
        }
        printf("Final counter value: %d\n", atomic_load(&counter));
        return 0;
    }
    
    在这个示例中,使用 atomic_int 类型的 counter 作为无锁计数器,通过 atomic_fetch_add 原子操作来实现多线程安全的计数功能,避免了使用锁带来的开销。

线程局部存储(Thread - Local Storage,TLS)

  1. 原理:线程局部存储允许每个线程拥有自己独立的变量副本,这些变量对于其他线程是不可见的。这样,每个线程可以独立地操作自己的变量副本,避免了对共享变量的竞争。
  2. 使用方法:在 Linux C 语言中,可以使用 pthread_key_t 类型来创建线程局部存储键,相关函数如下:
    • 创建线程局部存储键
      int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
      
      key 是指向要创建的键的指针,destructor 是一个可选的析构函数,当线程退出时,会调用该析构函数来释放与该键关联的线程局部存储数据。
    • 设置线程局部存储数据
      int pthread_setspecific(pthread_key_t key, const void *value);
      
      valuekey 关联,存储在线程局部存储中。
    • 获取线程局部存储数据
      void *pthread_getspecific(pthread_key_t key);
      
      获取与 key 关联的线程局部存储数据。
  3. 代码示例
    #include <stdio.h>
    #include <pthread.h>
    
    pthread_key_t key;
    
    void cleanup(void *arg) {
        printf("Cleaning up thread - local data: %d\n", *(int *)arg);
        free(arg);
    }
    
    void *threadFunction(void *arg) {
        int *localData = (int *)malloc(sizeof(int));
        *localData = *((int *)arg);
        pthread_setspecific(key, localData);
        printf("Thread %ld has local data: %d\n", (long)pthread_self(), *localData);
        // 模拟一些操作
        sleep(1);
        return NULL;
    }
    
    int main() {
        pthread_t thread1, thread2;
        int data1 = 10, data2 = 20;
    
        pthread_key_create(&key, cleanup);
    
        pthread_create(&thread1, NULL, threadFunction, &data1);
        pthread_create(&thread2, NULL, threadFunction, &data2);
    
        pthread_join(thread1, NULL);
        pthread_join(thread2, NULL);
    
        pthread_key_delete(key);
        return 0;
    }
    
    在这个示例中,每个线程通过 pthread_setspecific 函数将自己的数据存储在线程局部存储中,通过 pthread_getspecific 函数获取自己的数据,避免了数据竞争。同时,通过 pthread_key_delete 函数删除键,并在键创建时指定了清理函数 cleanup,用于在线程退出时释放线程局部存储的数据。

通过合理使用上述方法,可以有效地解决 Linux C 语言多线程服务器中的资源竞争问题,提高服务器的性能和稳定性。在实际开发中,需要根据具体的应用场景和需求,选择合适的同步机制和方法。例如,对于读多写少的场景,读写锁可能是一个较好的选择;而对于简单的共享资源保护,互斥锁可能就足够了。同时,要注意避免死锁等问题,确保多线程程序的正确性和可靠性。