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

Linux C语言读写锁提升并发性能

2021-09-174.7k 阅读

读写锁的基本概念

读写锁的定义

在多线程编程环境中,当多个线程需要访问共享资源时,为了避免数据竞争和不一致性问题,我们通常会使用锁机制。读写锁(Read-Write Lock)是一种特殊类型的锁,它将对共享资源的访问分为两种类型:读操作和写操作。读操作允许多个线程同时进行,因为读操作不会修改共享资源,不会产生数据竞争问题;而写操作则必须是互斥的,因为写操作会修改共享资源,如果多个写操作同时进行或者写操作与读操作同时进行,就会导致数据不一致。

读写锁与互斥锁的区别

互斥锁(Mutex)是一种最基本的锁机制,它在同一时间只允许一个线程进入临界区访问共享资源。无论是读操作还是写操作,都需要先获取互斥锁。这就意味着,如果一个线程正在进行读操作,其他线程想要进行读操作或者写操作都必须等待该线程释放互斥锁。这种机制在读写操作频繁且读操作远远多于写操作的场景下,会造成大量的线程等待,降低系统的并发性能。

而读写锁则针对这种情况进行了优化。对于读操作,只要没有写操作在进行,多个读线程可以同时获取读锁进行读操作;只有当有写操作时,才需要独占资源,其他读线程和写线程都必须等待写操作完成并释放写锁。这样,在以读为主的场景下,读写锁能够显著提高系统的并发性能。

Linux 下 C 语言中的读写锁

读写锁的相关函数

在 Linux 环境下,C 语言提供了一套用于操作读写锁的函数,这些函数定义在 <pthread.h> 头文件中。

  1. 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);

pthread_rwlock_init 函数用于初始化一个读写锁。rwlock 是指向要初始化的读写锁变量的指针,attr 是指向读写锁属性对象的指针,如果为 NULL,则使用默认属性。成功时返回 0,否则返回错误码。

  1. 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

pthread_rwlock_destroy 函数用于销毁一个读写锁。rwlock 是指向要销毁的读写锁变量的指针。在销毁读写锁之前,必须确保所有线程都已经释放了该读写锁。成功时返回 0,否则返回错误码。

  1. 获取读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

pthread_rwlock_rdlock 函数用于获取读锁。如果当前没有写锁被持有,并且没有写锁请求等待,则调用线程获取读锁并继续执行。如果有写锁被持有或者有写锁请求等待,则调用线程将被阻塞,直到读锁可以被获取。成功时返回 0,否则返回错误码。

  1. 获取写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

pthread_rwlock_wrlock 函数用于获取写锁。如果当前没有其他线程持有读锁或写锁,则调用线程获取写锁并继续执行。否则,调用线程将被阻塞,直到写锁可以被获取。成功时返回 0,否则返回错误码。

  1. 尝试获取读锁
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

pthread_rwlock_tryrdlock 函数尝试获取读锁。如果当前没有写锁被持有,并且没有写锁请求等待,则调用线程获取读锁并返回 0。否则,该函数立即返回 EBUSY,表示读锁无法获取,调用线程不会被阻塞。

  1. 尝试获取写锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

pthread_rwlock_trywrlock 函数尝试获取写锁。如果当前没有其他线程持有读锁或写锁,则调用线程获取写锁并返回 0。否则,该函数立即返回 EBUSY,表示写锁无法获取,调用线程不会被阻塞。

  1. 释放锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

pthread_rwlock_unlock 函数用于释放读锁或写锁。rwlock 是指向要释放的读写锁变量的指针。成功时返回 0,否则返回错误码。

读写锁的属性

读写锁也有一些属性可以设置,通过 pthread_rwlockattr_t 结构体来管理。常见的属性设置函数有:

  1. 初始化属性对象
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

pthread_rwlockattr_init 函数用于初始化一个读写锁属性对象。attr 是指向要初始化的读写锁属性对象的指针。成功时返回 0,否则返回错误码。

  1. 销毁属性对象
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);

pthread_rwlockattr_destroy 函数用于销毁一个读写锁属性对象。attr 是指向要销毁的读写锁属性对象的指针。在销毁属性对象之前,必须确保没有读写锁使用该属性对象。成功时返回 0,否则返回错误码。

  1. 设置读写锁的类型
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int kind);

pthread_rwlockattr_setkind_np 函数用于设置读写锁的类型。attr 是指向读写锁属性对象的指针,kind 可以取值为 PTHREAD_RWLOCK_PREFER_READER_NP(优先读者,默认值)、PTHREAD_RWLOCK_PREFER_WRITER_NP(优先写者)或 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP(优先写者且非递归)。成功时返回 0,否则返回错误码。

读写锁提升并发性能的原理

读操作的并发执行

在多线程环境中,当多个线程进行读操作时,由于读操作不会修改共享资源,所以它们之间不会产生数据竞争问题。读写锁利用这一特性,允许多个读线程同时获取读锁并执行读操作。这样,在以读为主的场景下,大量的读线程可以并行执行,而不需要像使用互斥锁那样依次等待,从而大大提高了系统的并发性能。

例如,假设有一个共享的数据库表,多个线程需要频繁读取其中的数据。如果使用互斥锁,每次只能有一个线程进行读操作,其他线程必须等待,这会导致大量的线程阻塞。而使用读写锁,只要没有写操作在进行,多个读线程可以同时读取数据,提高了读操作的并发度。

写操作的独占性

虽然读操作可以并发执行,但写操作必须是独占的。这是因为写操作会修改共享资源,如果多个写操作同时进行或者写操作与读操作同时进行,就会导致数据不一致。当一个线程请求写锁时,读写锁会阻止其他读线程和写线程获取锁,直到写操作完成并释放写锁。这样可以保证在写操作期间,共享资源不会被其他线程修改,从而维护数据的一致性。

例如,当一个线程需要更新数据库表中的数据时,它必须先获取写锁。在获取写锁后,其他读线程和写线程都不能访问该表,直到写操作完成并释放写锁。这样可以确保写操作对数据的修改是原子性的,不会被其他线程干扰。

读写优先级策略

读写锁通常支持不同的优先级策略,以满足不同的应用场景需求。

  1. 优先读者策略:这是默认的策略,即 PTHREAD_RWLOCK_PREFER_READER_NP。在这种策略下,当有读锁请求和写锁请求同时存在时,只要当前没有写锁被持有,读锁请求会优先被满足。这对于以读为主的应用场景非常合适,因为可以尽量减少读线程的等待时间,提高读操作的并发性能。

  2. 优先写者策略:即 PTHREAD_RWLOCK_PREFER_WRITER_NP。在这种策略下,当有读锁请求和写锁请求同时存在时,写锁请求会优先被满足。这对于写操作比较重要且需要及时处理的应用场景非常有用,例如一些实时数据更新的系统,写操作的及时性更为关键。

  3. 优先写者且非递归策略:即 PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP。这种策略在优先写者的基础上,不允许写线程递归获取写锁。递归获取锁可能会导致死锁等问题,在一些对死锁敏感的场景下,这种策略可以避免潜在的死锁风险。

代码示例

简单的读写锁示例

下面是一个简单的 C 语言代码示例,展示了如何使用读写锁来保护共享资源。在这个示例中,我们有一个共享变量 shared_data,多个读线程可以同时读取该变量,而写线程在写入时需要独占该变量。

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

// 共享数据
int shared_data = 0;
// 读写锁
pthread_rwlock_t rwlock;

// 读线程函数
void* reader(void* arg) {
    int id = *((int*)arg);
    pthread_rwlock_rdlock(&rwlock);
    printf("Reader %d is reading: %d\n", id, shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

// 写线程函数
void* writer(void* arg) {
    int id = *((int*)arg);
    pthread_rwlock_wrlock(&rwlock);
    shared_data++;
    printf("Writer %d is writing: %d\n", id, shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    // 初始化读写锁
    if (pthread_rwlock_init(&rwlock, NULL) != 0) {
        printf("\n rwlock init has failed\n");
        return 1;
    }

    // 创建线程
    pthread_t readers[5], writers[3];
    int reader_ids[5], writer_ids[3];

    for (int i = 0; i < 5; i++) {
        reader_ids[i] = i;
        pthread_create(&readers[i], NULL, reader, &reader_ids[i]);
    }

    for (int i = 0; i < 3; i++) {
        writer_ids[i] = i;
        pthread_create(&writers[i], NULL, writer, &writer_ids[i]);
    }

    // 等待线程结束
    for (int i = 0; i < 5; i++) {
        pthread_join(readers[i], NULL);
    }

    for (int i = 0; i < 3; i++) {
        pthread_join(writers[i], NULL);
    }

    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);

    return 0;
}

在这个示例中,我们首先定义了一个共享变量 shared_data 和一个读写锁 rwlock。然后,我们定义了读线程函数 reader 和写线程函数 writer。读线程函数在读取共享数据之前获取读锁,读取完成后释放读锁;写线程函数在写入共享数据之前获取写锁,写入完成后释放写锁。

main 函数中,我们初始化了读写锁,创建了 5 个读线程和 3 个写线程,并等待它们执行完毕。最后,我们销毁了读写锁。

带有优先级设置的读写锁示例

下面的示例展示了如何设置读写锁的优先级。在这个示例中,我们将读写锁的类型设置为优先写者策略。

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

// 共享数据
int shared_data = 0;
// 读写锁
pthread_rwlock_t rwlock;
// 读写锁属性
pthread_rwlockattr_t rwlock_attr;

// 读线程函数
void* reader(void* arg) {
    int id = *((int*)arg);
    pthread_rwlock_rdlock(&rwlock);
    printf("Reader %d is reading: %d\n", id, shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

// 写线程函数
void* writer(void* arg) {
    int id = *((int*)arg);
    pthread_rwlock_wrlock(&rwlock);
    shared_data++;
    printf("Writer %d is writing: %d\n", id, shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    // 初始化读写锁属性
    if (pthread_rwlockattr_init(&rwlock_attr) != 0) {
        printf("\n rwlockattr init has failed\n");
        return 1;
    }

    // 设置读写锁类型为优先写者
    if (pthread_rwlockattr_setkind_np(&rwlock_attr, PTHREAD_RWLOCK_PREFER_WRITER_NP) != 0) {
        printf("\n setkind_np has failed\n");
        return 1;
    }

    // 初始化读写锁
    if (pthread_rwlock_init(&rwlock, &rwlock_attr) != 0) {
        printf("\n rwlock init has failed\n");
        return 1;
    }

    // 创建线程
    pthread_t readers[5], writers[3];
    int reader_ids[5], writer_ids[3];

    for (int i = 0; i < 5; i++) {
        reader_ids[i] = i;
        pthread_create(&readers[i], NULL, reader, &reader_ids[i]);
    }

    for (int i = 0; i < 3; i++) {
        writer_ids[i] = i;
        pthread_create(&writers[i], NULL, writer, &writer_ids[i]);
    }

    // 等待线程结束
    for (int i = 0; i < 5; i++) {
        pthread_join(readers[i], NULL);
    }

    for (int i = 0; i < 3; i++) {
        pthread_join(writers[i], NULL);
    }

    // 销毁读写锁属性
    pthread_rwlockattr_destroy(&rwlock_attr);
    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);

    return 0;
}

在这个示例中,我们首先初始化了读写锁属性对象 rwlock_attr,然后使用 pthread_rwlockattr_setkind_np 函数将读写锁的类型设置为优先写者策略。接着,我们使用带有属性的 pthread_rwlock_init 函数初始化读写锁。在程序结束时,我们销毁了读写锁属性对象和读写锁。

读写锁在实际项目中的应用场景

数据库缓存系统

在数据库缓存系统中,经常会有大量的读操作和少量的写操作。例如,一个 Web 应用程序可能会从数据库中读取用户信息,并将这些信息缓存到内存中以提高访问速度。多个线程可能会同时请求读取缓存中的用户信息,而只有在用户信息发生变化时,才需要进行写操作来更新缓存。

在这种场景下,使用读写锁可以有效地提高系统的并发性能。读线程可以同时获取读锁读取缓存数据,而写线程在更新缓存时获取写锁,保证数据的一致性。这样可以避免使用互斥锁导致的读线程大量等待的问题,提高系统的整体性能。

文件系统缓存

文件系统缓存也是一个适合使用读写锁的场景。当多个进程或线程需要读取文件内容时,文件系统可能会将文件数据缓存到内存中。读操作可以并发进行,因为它们不会修改缓存中的数据。而当文件内容发生变化时,需要进行写操作来更新缓存。

通过使用读写锁,读操作可以并行执行,提高文件读取的效率;写操作则独占缓存资源,确保文件数据的一致性。这对于提高文件系统的性能和响应速度非常重要。

分布式系统中的数据同步

在分布式系统中,不同节点之间需要同步数据。例如,一个分布式数据库可能会在多个节点上存储相同的数据副本,以提高可用性和性能。当一个节点需要更新数据时,它需要获取写锁,确保在更新过程中其他节点不会同时进行写操作或读操作,以避免数据不一致。

而在正常情况下,多个节点可以同时读取数据副本,以满足大量的读请求。通过使用读写锁,分布式系统可以在保证数据一致性的前提下,提高数据同步和访问的并发性能。

读写锁使用中的注意事项

死锁问题

虽然读写锁相对于互斥锁在并发性能上有很大的提升,但如果使用不当,仍然可能会导致死锁问题。例如,一个线程在持有读锁的情况下又尝试获取写锁,而另一个线程持有写锁,这就会导致死锁。

为了避免死锁,在设计程序时需要遵循一定的原则。例如,尽量按照相同的顺序获取锁,避免在持有锁的情况下尝试获取其他锁,特别是当这些锁的获取顺序不确定时。同时,在使用递归锁(如果支持)时要格外小心,确保递归获取锁的逻辑是正确的,不会导致死锁。

性能调优

虽然读写锁在以读为主的场景下能够提高并发性能,但在实际应用中,还需要根据具体的业务场景进行性能调优。例如,选择合适的读写优先级策略。如果应用程序中写操作的实时性要求较高,那么优先写者策略可能更合适;如果读操作非常频繁且对响应时间要求较高,那么优先读者策略可能更能满足需求。

此外,还需要注意锁的粒度。如果锁的粒度过大,会导致过多的线程等待,降低并发性能;如果锁的粒度过小,会增加锁的开销,也可能影响性能。因此,需要根据共享资源的访问模式和业务需求,合理调整锁的粒度。

线程安全问题

在使用读写锁时,要确保所有对共享资源的访问都通过读写锁进行保护。如果有部分代码没有正确使用读写锁,就可能会导致数据竞争和不一致问题。这就要求在编写代码时,要仔细检查对共享资源的每一次访问,确保都在锁的保护范围内。

同时,要注意线程的生命周期和锁的释放。如果一个线程在持有锁的情况下异常终止,而没有释放锁,那么其他线程可能会永远等待该锁,导致系统死锁或性能下降。因此,在编写线程函数时,要确保在异常情况下也能正确释放锁。

综上所述,在 Linux C 语言编程中,合理使用读写锁可以显著提升并发性能,但需要注意避免死锁、进行性能调优以及确保线程安全等问题。通过深入理解读写锁的原理和使用方法,并结合具体的业务场景进行优化,我们可以开发出高效、稳定的多线程应用程序。