Linux多线程编程中的同步与互斥

一、线程同步与互斥

1、理解线程同步

线程同步是多个线程协调彼此操作以完成特定任务的过程。它确保线程按照一定的顺序执行,以避免竞态条件和数据不一致性问题。在多线程环境中,线程同步涉及到控制线程的执行顺序和对共享资源的访问。

竞态条件指的是在多线程或并发环境下,程序的执行结果依赖于线程执行的具体时序或顺序。

具体来说,竞态条件发生在多个线程试图同时访问共享资源,并且对资源的访问顺序未经过适当的同步控制时。在竞态条件下,由于线程执行顺序的不确定性,程序的行为可能变得不可预测,导致出现错误或不一致的结果。

竞态条件通常发生在以下情况下:

  1. 共享资源访问:多个线程试图同时读取或写入共享的内存、文件、数据库等资源。
  2. 非原子操作:针对共享资源的操作不是原子的,无法保证在同一时刻只有一个线程能够执行完整的操作。
  3. 未经同步控制的条件判断:线程在执行过程中依赖于某些条件的判断,但这些条件的状态可能在多线程环境下发生变化,从而导致不一致的结果。

竞态条件的存在可能导致程序的不正确行为,例如数据损坏、结果不确定、死锁等问题。因此,在多线程编程中,需要使用适当的同步机制来避免竞态条件的发生,确保线程的正确执行顺序和共享资源的正确访问。

那么线程同步的重要性就不言而喻了。正确地实现线程同步可以避免数据竞态、死锁和其他并发问题,从而确保程序的预期行为。在并发环境中,线程同步是确保程序正常运行的基础,尤其是在需要多个线程协作完成复杂任务的情况下。

2、互斥的概念

互斥是指在多线程环境下,某些资源或临界区域只能被一个线程访问,而其他任务或线程必须等待前一个线程释放资源或完成操作后才能进行访问。

互斥保证了资源的独占性,避免了多个任务或线程同时对同一资源进行操作而导致的数据不一致或竞争条件的问题。

互斥的需求主要源于以下两个方面:

  1. 数据一致性:在多任务或多线程环境下,如果多个任务或线程同时对共享资源进行读写操作,可能会导致数据不一致的问题。通过互斥,可以确保同一时间只有一个任务或线程对资源进行访问,从而维护数据的一致性。

  2. 竞争条件:多个任务或线程同时对共享资源进行写操作时,可能会产生竞争条件,导致程序行为不确定或不正确。互斥可以解决这种竞争条件,通过对资源的访问进行排他性控制,确保不会发生同时写的情况,从而避免竞态条件的问题。

临界区:临界区是指在多线程环境下,多个线程访问共享资源的那部分代码区域。在临界区内,线程对共享资源的访问是需要互斥的。

临界资源:临界资源是指在多线程环境下被多个线程共享的资源,例如共享内存、全局变量、文件等。这些资源的共享可能会导致竞争条件和数据不一致的问题,因此需要通过互斥机制来保护。

临界资源通常与临界区相关联,多个线程在访问临界资源时需要进入临界区执行相应的代码,而且在同一时间只能有一个线程访问临界资源。

3、小结

线程同步和线程互斥是多线程编程中两个相关但又不同的概念。

  1. 线程同步(Thread Synchronization):指的是多个线程之间协调和合作,以确保它们在特定时刻执行的顺序、时间或者条件达成。线程同步的目的是保证多个线程能够在正确的时间点上相互配合,以完成某项任务。常见的线程同步技术包括互斥锁、条件变量、信号量、屏障等。例如,在生产者-消费者问题中,需要通过同步机制确保生产者和消费者之间的数据交换是安全和有效的。
  2. 线程互斥(Thread Mutual Exclusion):指的是通过使用互斥锁等机制,来防止多个线程同时访问临界资源,从而避免数据竞争和不一致性问题。互斥锁是一种常用的同步机制,用于保护共享资源,确保在任何时刻只有一个线程能够访问该资源。线程互斥的目的是防止多个线程同时修改共享资源,从而保证数据的完整性和一致性。

二、互斥锁(Mutex)

1、互斥锁的定义和作用

互斥锁用于保护共享资源,确保在任意时刻只有一个线程可以访问该资源。它允许一个线程进入临界区执行操作,而其他线程必须等待当前线程释放锁后才能进入。互斥锁的作用是防止多个线程同时访问共享资源,从而避免竞争条件和数据不一致的问题。

2、pthread库中的互斥锁

❔ 我们为什么要使用互斥锁呢?

共享资源在多线程环境下可能会被多个线程同时访问,如果没有合适的同步机制来保护这些资源,就会出现数据竞争和数据不一致的问题。使用互斥锁可以确保在同一时间只有一个线程可以访问共享资源,从而避免这些问题的发生。

❓ 那么我们如何使用互斥锁呢?

当涉及到多线程编程时,互斥锁(Mutex)是用于保护共享资源的一种重要机制。在 POSIX 线程库中,有四个关键的函数用于互斥锁的操作:

在这里插入图片描述

pthread_mutex_destroypthread_mutex_init 是 POSIX 线程库中用于管理互斥锁(mutex)的两个函数。

pthread_mutex_destroy 函数用于销毁一个已初始化的互斥锁。在销毁互斥锁之后,它就不能再被用于同步了。如果互斥锁当前被锁定,或者其状态无效(例如,未正确初始化),则 pthread_mutex_destroy 的行为是未定义的。

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • mutex:指向要销毁的互斥锁的指针。

pthread_mutex_init 函数用于初始化一个未初始化的互斥锁。初始化后,互斥锁可用于同步线程。

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);
  • mutex:指向要初始化的互斥锁的指针。
  • attr:指向互斥锁属性的指针,或者为 NULL 以使用默认属性。在大多数应用中,可以使用NULL

静态初始化:除了使用 pthread_mutex_init 函数进行动态初始化外,还可以使用 PTHREAD_MUTEX_INITIALIZER 宏进行静态初始化:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

这种方式在全局或静态变量初始化时很有用,因为它不需要调用任何函数,也不需要进行错误检查。其内部状态会在程序结束时由操作系统自动清理。因此,无需手动调用pthread_mutex_destroy()来销毁这种类型的互斥锁。

注意:在调用 pthread_mutex_destroy 之前,请确保没有线程正在等待该互斥锁,否则行为是未定义的。同样,在销毁互斥锁后,不要再次使用它,除非重新初始化。

下面的函数是用于管理 POSIX 线程中的互斥锁的。这些函数分别用于锁定、尝试锁定和解锁互斥锁。

在这里插入图片描述

pthread_mutex_lock 函数用于锁定指定的互斥锁。如果互斥锁已经被另一个线程持有,则调用线程将被阻塞,直到获取到互斥锁。

int pthread_mutex_lock(pthread_mutex_t *mutex);
  • mutex:指向要锁定的互斥锁的指针。

pthread_mutex_trylock()函数尝试获取互斥锁,与pthread_mutex_lock()类似。

int pthread_mutex_trylock(pthread_mutex_t *mutex);
  • mutex:指向要尝试锁定的互斥锁的指针。

但是如果互斥锁已经被其他线程持有,则该函数不会阻塞当前线程,而是立即返回一个结果。具体来说:

  • 如果成功获取到互斥锁,则函数返回0。
  • 如果互斥锁已经被其他线程持有,则函数立即返回EBUSY错误码(被锁定)。
  • 如果函数调用失败,可能会返回其他错误码,例如EAGAIN(系统资源不足)。

这个函数通常用于在获取互斥锁时不希望线程被阻塞的情况下,进行一次非阻塞的尝试。

pthread_mutex_unlock 函数用于解锁指定的互斥锁,即释放互斥锁。解锁后,其他线程可以获取该互斥锁。如果调用线程没有锁定该互斥锁,则行为是未定义的。

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • mutex:指向要解锁的互斥锁的指针。

pthread中互斥锁相关函数的通用惯例是,执行成功时返回0,否则返回相应的错误码。

3、互斥锁的实现原理

一般来说,互斥锁的实现会利用操作系统提供的原子操作来实现。在多线程环境下,每个互斥锁会有一个状态标志,当某个线程获取到锁时,状态标志被设置为锁定状态,其他线程尝试获取锁时会被阻塞,直到锁被释放。

在这里插入图片描述

多线程编程中互斥锁的锁和解锁:

  • 在线程进入临界区之前,它必须首先获得锁。获得锁的操作是原子性的,这意味着它要么完全成功,要么完全失败,且在此过程中不会被其他线程打断。

  • 当线程完成临界区内的操作后,它必须释放锁,以便其他线程有机会获得锁并进入临界区。

  • 锁定和解锁机制确保了任何时刻只有一个线程可以访问临界区,从而实现了对共享资源的互斥访问。

原子操作是一个不可分割的单元,要么全部执行成功,要么全部不执行,并且在执行过程中不会被其他操作中断。

让我们谈一下互斥锁(Mutex)的工作原理:互斥锁通常是一个存储在内存中的变量(或更复杂的数据结构),它用于控制对共享资源的访问。

当一个线程尝试获取互斥锁时,首先,线程会检查互斥锁的状态,通常以某个变量或标志表示。假设该变量的值为1表示锁未被持有,为0表示锁已被持有。如果线程发现锁的状态为1,即表示锁未被其他线程持有,那么它将执行获取锁的操作,并将锁的状态修改为0,标志自己已经成功获取了锁。然而,如果线程发现锁的状态为0,即表示锁已经被其他线程持有,那么它将无法立即获取锁。在这种情况下,线程会被阻塞等待,直到锁的状态变为1。

互斥锁的原子性是通过操作系统提供的原子操作或锁机制来实现的。这些机制确保了在任何时刻只有一个线程能够成功地修改锁的状态,以避免竞争条件和数据不一致性的问题。

因此,互斥锁的交换或修改操作本质上是在内存中修改锁的状态。这个过程确保了只有一个线程能够访问共享资源,从而避免了数据竞争和不一致性。

临界区内部,正在访问临界区的线程,会被操作系统切换吗?

当一个线程持有锁并正在访问临界区时,即便该线程被切走,其他线程不能进入临界区,不能获取这把锁。因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁。它们必须等待,直到锁被释放。一旦锁被释放,其他线程中的一个(根据调度策略)将能够获取锁并访问临界区。

4、示例代码演示互斥锁的基本用法

下面是一个示例代码,演示了使用 pthread 库函数来实现互斥锁的基本用法:

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 初始化互斥锁

void *print_message(void *id_ptr) {
    int id = *((int *)id_ptr);
    pthread_mutex_lock(&mutex); // 获取互斥锁
    printf("Thread %d is printing a message.\n", id);
    pthread_mutex_unlock(&mutex); // 释放互斥锁
    pthread_exit(NULL);
}

int main() {
    pthread_t tid1, tid2;
    int id1 = 1, id2 = 2;

    // 创建两个线程
    if (pthread_create(&tid1, NULL, print_message, &id1) != 0) {
        perror("pthread_create");
        exit(1);
    }
    if (pthread_create(&tid2, NULL, print_message, &id2) != 0) {
        perror("pthread_create");
        exit(1);
    }

    // 等待两个线程执行完毕
    if (pthread_join(tid1, NULL) != 0) {
        perror("pthread_join");
        exit(1);
    }
    if (pthread_join(tid2, NULL) != 0) {
        perror("pthread_join");
        exit(1);
    }

    pthread_mutex_destroy(&mutex); // 销毁互斥锁
    return 0;
}

我们定义了一个全局的互斥锁对象 mutex,并使用 PTHREAD_MUTEX_INITIALIZER 进行初始化。接着,我们定义了一个名为 print_message 的函数,用于在线程中输出信息。在函数内部,我们使用 pthread_mutex_lock() 获取互斥锁,然后输出线程的信息,并在最后使用 pthread_mutex_unlock() 来释放互斥锁。

main 函数中,我们创建了两个线程 tid1tid2,分别调用 print_message 函数来输出信息。然后我们使用 pthread_join 函数等待两个线程执行完毕后再结束程序,并在最后使用 pthread_mutex_destroy() 销毁互斥锁。


三、条件变量(Condition Variable)

1、条件变量的定义和作用

条件变量是一种线程同步的机制,用于实现线程间的条件等待和通知。它允许一个线程在等待某个特定条件为真时进入睡眠状态,同时允许其他线程在条件变量满足特定条件时唤醒等待的线程。

条件变量通常与互斥锁一起使用,以确保在检查条件和进入等待状态之间的原子性操作。

条件变量通常与互斥锁一起使用,以确保等待线程在检查条件和进入等待状态之间不会发生竞态条件。等待线程首先获取互斥锁,然后检查条件是否满足,如果条件不满足,则等待在条件变量上。当其他线程改变了条件并且发出信号时,等待线程被唤醒并重新检查条件。

2、pthread库中的条件变量

在这里插入图片描述

pthread_cond_destroypthread_cond_init 是 POSIX 线程库中的函数,用于初始化和销毁条件变量。

pthread_cond_init这个函数用于初始化一个条件变量。它的原型是:

int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);
  • cond:指向要初始化的条件变量的指针。
  • attr:一个指向条件变量属性的指针,用于设置条件变量的属性。通常,这个参数可以设置为 NULL,以使用默认属性。

pthread_cond_destroy这个函数用于销毁一个已经初始化的条件变量。它的原型是:

int pthread_cond_destroy(pthread_cond_t *cond);
  • cond:指向要销毁的条件变量的指针。

注意,在销毁一个条件变量之前,应该确保没有线程正在等待该条件变量,否则可能会导致未定义的行为。

静态初始化: 除了使用 pthread_cond_init 函数进行动态初始化之外,还可以使用宏 PTHREAD_COND_INITIALIZER 进行静态初始化。这种静态初始化方法只适用于在程序开始时就需要使用的条件变量,并且不允许更改其属性。

在这里插入图片描述

pthread_cond_timedwaitpthread_cond_wait 是 POSIX 线程库中用于线程等待条件变量的函数。这两个函数允许线程在继续执行之前等待一个条件变为真。以下是这两个函数的详细描述:

pthread_cond_wait: 使线程等待一个条件变量变为真。在等待期间,线程会释放与之关联的互斥锁,以便其他线程可以修改条件。当条件变量被其他线程通过 pthread_cond_signalpthread_cond_broadcast 唤醒时,pthread_cond_wait 会重新获取互斥锁并返回。

int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);
  • cond:指向要等待的条件变量的指针。
  • mutex:指向与条件变量关联的互斥锁的指针。

pthread_cond_timedwait :与 pthread_cond_wait 类似,但它允许线程在指定的绝对时间之后超时。如果超时发生或条件变量被其他线程唤醒,pthread_cond_timedwait 都会返回。

int pthread_cond_timedwait(pthread_cond_t *restrict cond,
                           pthread_mutex_t *restrict mutex,
                           const struct timespec *restrict abstime);
  • cond:指向要等待的条件变量的指针。
  • mutex:指向与条件变量关联的互斥锁的指针。
  • abstime:指向一个 struct timespec 结构的指针,该结构指定了线程应等待的绝对时间。

注意事项

  • 在调用 pthread_cond_waitpthread_cond_timedwait 之前,必须确保线程已经锁定了与条件变量关联的互斥锁。
  • 当这些函数返回时,它们会自动重新锁定互斥锁。
  • 如果在调用 pthread_cond_waitpthread_cond_timedwait 之后,线程决定不继续等待条件(即,它检测到条件已经为真),那么它应该立即通过调用 pthread_mutex_unlock 释放互斥锁,以避免死锁。
  • 在使用条件变量时,通常的做法是将它们与互斥锁一起使用,以确保对共享数据的正确同步。

在这里插入图片描述

pthread_cond_broadcastpthread_cond_signal 是 POSIX 线程库中用于唤醒等待在条件变量上的线程的函数。这些函数允许一个线程通知其他正在等待某个条件变为真的线程。以下是这两个函数的详细描述:

pthread_cond_signal :用于唤醒等待在指定条件变量上的一个线程。如果有多个线程正在等待该条件变量,那么具体唤醒哪个线程是由系统调度器决定的,通常是基于某种优先级或调度策略。

int pthread_cond_signal(pthread_cond_t *cond);
  • cond:指向要发送信号的条件变量的指针。

pthread_cond_broadcast :与 pthread_cond_signal 类似,但它是用来唤醒等待在指定条件变量上的所有线程的。这对于那些需要所有等待线程都被唤醒的情况很有用。

int pthread_cond_broadcast(pthread_cond_t *cond);
  • cond:指向要广播信号的条件变量的指针。

注意事项

  • 在调用 pthread_cond_signalpthread_cond_broadcast 之前,必须确保已经通过调用如 pthread_mutex_lock 的函数锁定了与条件变量关联的互斥锁。这是因为在修改任何与条件变量相关的共享数据或状态之前,需要确保数据的完整性和一致性。
  • 在调用 pthread_cond_signalpthread_cond_broadcast 之后,应该立即通过调用 pthread_mutex_unlock 来释放互斥锁,以便被唤醒的线程可以获取锁并继续执行。
  • 被唤醒的线程在继续执行之前会重新锁定互斥锁(如果它还没有锁定的话),这是由 pthread_cond_waitpthread_cond_timedwait 函数的实现保证的。

为什么 pthread_cond_wait 需要互斥量呢?

pthread_cond_wait 函数需要与互斥锁一起使用的主要原因是确保在等待条件期间对共享数据的访问是安全的。

当线程进入临界区时需要先加锁,然后判断内部资源的情况,若不满足当前线程的执行条件,则需要在该条件变量下进行等待,但此时该线程是拿着锁被挂起的,也就意味着这个锁再也不会被释放了,此时就会发生死锁问题。

在调用 pthread_cond_wait 之前,通常会先获取一个互斥锁。这是因为 pthread_cond_wait 函数在等待期间会释放该互斥锁,并使调用线程进入休眠状态。当其他线程满足条件并发出信号时,等待线程将被唤醒,并在重新获取互斥锁后继续执行,这样可以确保在等待时互斥锁是被释放的,其他线程可以修改条件变量并发送信号。因此在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入,此时当线程因为某些条件不满足需要在该条件变量下进行等待时,就会自动释放该互斥锁。

为了保证解锁和等待操作的原子性,pthread_cond_wait 函数内部会将当前线程放入等待队列之前,先将互斥锁解锁,然后在被唤醒后重新获取互斥锁。这样可以避免在这两个操作之间发生上下文切换,确保了原子性。这也是为什么在调用 pthread_cond_wait 函数时需要传入一个互斥锁的原因。

如果没有互斥锁保护,可能会出现以下问题:

  1. 竞态条件:在等待条件期间,其他线程可能会修改共享数据,导致数据的不一致性和错误的结果。

  2. 信号丢失:在等待条件期间,如果条件已经满足但尚未发出信号,那么当调用线程调用 pthread_cond_wait 时,它会立即进入休眠状态,从而错过了信号。

    pthread_mutex_lock(&mutex);
    while (condition_is_false) {
    	pthread_mutex_unlock(&mutex);
    	//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
    	pthread_cond_wait(&cond);
    	pthread_mutex_lock(&mutex);
    }
    pthread_mutex_unlock(&mutex);
    
    

    由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。

通过使用互斥锁,可以确保在等待条件期间对共享数据的访问是互斥的,避免了竞态条件的发生。同时,互斥锁的释放和重新获取保证了等待线程在重新唤醒后能够正确地继续执行。

条件变量使用规范:

pthread_mutex_lock(&mutex);
while (条件为假){
    pthread_cond_wait(&cond, &mutex);
}
//修改条件
pthread_mutex_unlock(&mutex);
  • 首先,线程获取互斥锁 mutex,这是为了保护条件变量和共享资源的访问,以避免竞态条件的发生。

  • 然后,在进入等待循环之前,使用 while 循环来检查条件是否为假。这个循环是为了防止条件变量被伪唤醒。

    • 伪唤醒是指在没有收到信号的情况下,等待条件变量的线程被唤醒的情况。为了防止虚假唤醒,通常在等待条件变量时会使用 while 循环来检查条件是否满足,而不是简单地使用 if 语句。

      使用 while 循环而不是 if 语句是为了防止在没有收到信号的情况下虚假唤醒。如果使用 if 语句,那么即使条件不满足,线程也可能在没有收到信号的情况下被唤醒,然后继续执行后续代码。这可能导致程序逻辑错误。使用 while 循环的原理是,当一个线程被唤醒后,它会重新检查条件。如果条件仍然不满足,则线程会继续等待。只有当条件满足时,线程才会继续执行后续代码。因此,使用 while 循环结合条件变量的等待,可以有效地防止虚假唤醒的发生,确保了等待的正确性和可靠性。

  • 在等待条件时,调用 pthread_cond_wait 函数,并传入条件变量 cond 和互斥锁 mutex。这个函数会自动释放互斥锁,并且将调用线程挂起,直到条件变为真或者被其他线程发送信号唤醒。

  • 在收到信号并重新获取互斥锁后,线程会重新检查条件,如果条件仍然为假,则继续等待。如果条件已经变为真,线程将继续执行修改条件的操作。

  • 最后,在修改条件完成后,释放互斥锁以允许其他线程访问共享资源。

pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
  • 类似地,线程在修改条件之前先获取互斥锁 mutex

  • 在设置条件为真后,调用 pthread_cond_signal 函数向等待在条件变量上的线程发送信号。这个函数会唤醒一个等待的线程,让其从等待中返回并重新获取互斥锁。

  • 最后,释放互斥锁以允许其他线程访问共享资源。

这样的设计确保了条件的安全等待和信号发送,同时避免了竞态条件的发生。


四、信号量(Semaphore)

1、信号量的定义和作用

信号量是一种用于控制对共享资源的访问的同步原语。它通常是一个非负整数,用于表示可用资源的数量。信号量的主要作用是协调多个线程或进程对共享资源的访问,以避免竞争条件和提高系统效率。信号量常被用于实现互斥(互斥锁是特殊类型的信号量,其值通常初始化为1)和同步:

  1. 互斥:互斥锁是信号量的一个特例,其值通常初始化为1。当一个线程需要访问共享资源时,它会尝试获取这个信号量(即将其值减1)。如果信号量的值大于0,则获取成功,线程可以访问共享资源;如果信号量的值为0,则线程必须等待,直到其他线程释放该信号量(即将其值加1)。通过这种方式,互斥锁可以确保在任何时候只有一个线程可以访问共享资源。
  2. 同步:除了用于互斥之外,信号量还可以用于同步多个线程或进程的活动。例如,可以使用信号量来确保一个线程在另一个线程完成某个任务之前不会开始执行。或者,可以使用信号量来限制同时访问共享资源的线程数量。

信号量是一种用来衡量资源数目的计数器。在进入临界资源之前,申请信号量时,我们已经知道是否有足够的资源。只要成功申请到信号量,就意味着有可用的资源。如果申请失败,那么表示当前没有可用的资源。这种机制有效地减少了在进入临界区之前需要进行的内部判断,使得资源的获取更加简洁和高效。

当我们想要访问一个共享资源(即进入临界区)时,我们会先尝试获取(或称为“请求”或“申请”)一个信号量。这个信号量的值表示当前可用的资源数量。如果信号量的值大于0,那么我们就可以安全地进入临界区,并且会将信号量的值减1(表示我们已经使用了一个资源)。如果信号量的值为0,那么我们就知道当前没有可用的资源,我们会被阻塞(即放入等待队列),直到有其他线程或进程释放资源(即将信号量的值增加)。

这种机制可以确保在任何时候都不会有超过指定数量的线程或进程同时访问共享资源,从而避免了竞态条件和数据不一致的问题。

2、信号量的基本类型和操作

“只要申请到信号量,资源必有。失败,则没有。”,这反映了信号量的基本工作原理:

  1. 申请信号量:当一个线程或进程想要访问一个受保护的资源时,它会尝试获取(或称为“P操作”或“wait”操作)一个信号量。如果信号量的值大于0,那么表示有可用的资源,信号量的值会减1,并且线程或进程会获得对资源的访问权限。这就是“只要申请到信号量,资源必有”的情况。
  2. 信号量失败:如果信号量的值为0,那么表示没有可用的资源。在这种情况下,尝试获取信号量的线程或进程会被阻塞(即暂停执行),直到有另一个线程或进程释放(或称为“V操作”或“signal”操作)资源并增加信号量的值。这就是“失败则没有”的情况。

通过这种方式,信号量可以确保在任何时候都不会有超过一定数量的线程或进程同时访问某个共享资源,从而避免了竞态条件和数据不一致的问题。

下面我们介绍信号量的P操作和V操作:

  • P操作(也称为wait):这个操作将信号量的值减1。如果结果值小于0,则进程将被阻塞(放入等待队列),直到信号量值变为非负值。
  • V操作(也称为signal):这个操作将信号量的值加1。如果有任何进程在等待队列中等待该信号量,则其中一个进程将被唤醒(从等待队列中移除)。

需要注意的是,虽然信号量可以管理对共享资源的访问,但它并不直接管理资源本身。也就是说,信号量只是一个计数器,用于跟踪可用资源的数量。实际的资源(如文件、内存块、数据库连接等)由操作系统或其他系统组件管理。

3、pthread库中的信号量

初始化信号量:

在这里插入图片描述

int sem_init(sem_t *sem, int pshared, unsigned int value)
  • sem:指向要初始化的信号量对象的指针。
  • pshared:指定信号量是否应在进程间共享。
  • value:信号量的初始值。

如果 pshared 为0,则信号量仅在进程内的线程之间共享。这意味着信号量应该位于所有线程都可以访问的地址上,例如全局变量或堆上动态分配的内存。

如果 pshared 非零,则信号量在进程间共享。这意味着信号量应该位于共享内存区域中(如通过shm_openmmapshmget创建的区域)。这样,任何可以访问该共享内存区域的进程都可以使用sem_postsem_wait等函数来操作该信号量。

销毁信号量:

在这里插入图片描述

sem_destroy 函数销毁由 sem 指向的未命名信号量。这个信号量必须之前已经被 sem_init 函数初始化过。销毁一个信号量后,任何对该信号量的后续使用(除非重新初始化)都会导致未定义的行为。

特别需要注意的是,如果销毁一个当前有线程或进程正在等待(通过 sem_wait 或其他相关函数)的信号量,将会产生未定义的行为。因此,在销毁信号量之前,必须确保没有任何线程或进程在等待该信号量。

P操作:等待信号量,会将信号量的值减1

在这里插入图片描述

int sem_wait(sem_t *sem);

sem_wait 函数试图将由 sem 指向的信号量减一(即锁定)。如果信号量的当前值大于零,则减一并立即返回。如果信号量的值为零,则调用线程会被阻塞,直到信号量的值变为大于零(即有其他线程或进程调用了 sem_post 来增加信号量的值),或者线程被信号中断。

V操作:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。

在这里插入图片描述

sem_post 函数将 sem 指向的信号量值加一。如果有其他线程或进程正在 sem_wait 调用中等待该信号量,那么其中一个等待的线程或进程将被唤醒并继续执行。这样,sem_post 可以用于通知等待的线程或进程资源已经可用。


五、可重入与线程安全

1、可重入

重入:同一个函数被不同的执行流调用,当前一个进程还没有执行完,就又其他的执行流再次进入,我们称之为重入。一个函数在被重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。

2、线程安全

线程安全是指在多线程环境下,共享资源能够被多个线程安全地访问和操作,而不会导致数据不一致或不可预料的结果。

3、小结

  • 可重入函数的概念是指一个函数可以被多个线程同时调用,而且不会产生竞态条件或不一致的结果。可重入函数不依赖于全局或静态变量,而是通过参数传递和局部变量来保持线程安全。

  • 线程安全函数是在多线程环境下能够被多个线程安全地调用的函数。这意味着即使多个线程同时调用该函数,也不会导致数据不一致或不可预期的结果。

  • 不可重入函数是指在执行过程中依赖于全局或静态变量的函数。这样的函数可能在并发环境中产生竞态条件,因此不适合被多个线程同时调用。

  • 如果一个函数中使用了全局变量,并且没有适当的同步机制来保护这些全局变量,那么这个函数既不是线程安全的,也不是可重入的。

  • 加锁可以确保临界资源的互斥访问,从而使函数成为线程安全的。但是,如果在函数内部没有正确地释放锁,可能会导致死锁等问题,因此这样的函数可能是不可重入的。

因此,可重入函数是线程安全函数的一种,但线程安全函数不一定是可重入的。即,线程安全不一定是可重入的,而可重入函数则一定是线程安全的。正确地设计和实现函数,避免全局状态的依赖,使用适当的同步机制,可以确保函数在多线程环境中的安全使用。

如果将临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁未释放,则会产生死锁,因此是不可重入的。


六、死锁

死锁:是多线程把锁不合理的使用,导致代码不会继续向后正常推进的情况。

死锁是指在多线程环境中,两个或多个线程因争夺资源而造成的一种互相等待的现象,每个线程都在等待其他线程释放资源,而没有线程来释放资源,导致所有线程都无法继续执行。

死锁的四个必要条件:

  1. 互斥条件:资源不能被多个线程共同使用,只能由一个线程独占。
  2. 持有和等待条件:线程至少持有一个资源,并且正在等待获取额外的资源,而该资源又被其他线程持有。
  3. 非抢占条件:线程持有的资源在未使用完之前不能被其他线程强行抢占。
  4. 循环等待条件:存在一个线程与资源之间的循环等待链,每个线程都在等待下一个线程所持有的资源。

当以上四个条件同时满足时,就可能导致死锁的发生。一旦发生死锁,各个进程或线程之间都无法继续执行,除非外部干预打破死锁。

简单一点就是:由条件能推出结论,但由结论推不出这个条件,这个条件就是充分条件。 如果能由结论推出条件,但由条件推不出结论,此条件为必要条件。

最后,我们来看几个面试题:

以下描述正确的有:ABC[多选]

A.pthread_create函数是一个库函数, 代码当中如果使用该函数创建线程, 则需要在编译的时候链接“libpthread.so”线程库

B.那个线程调用pthread_exit函数, 那个线程就退出。俗称“谁调用谁退出”

C.在有多个线程的情况下,主线程调用pthread_cancel(pthread_self()), 则主线程状态为Z, 其他线程正常运行

D.在有多个线程的情况下,主线程从main函数的return返回或者调用pthread_exit函数,则整个进程退出

C:主线程调用pthread_cancel(pthread_self())函数来退出自己, 则主线程对应的轻量级进程状态变更成为Z, 其他线程不受影响,这是正确的(正常情况下我们也不会这么做…)

D:主线程调用pthread_exit只是退出主线程,并不会导致进程的退出

请简述什么是LWP

LWP是轻量级进程,在Linux下进程是资源分配的基本单位,线程是cpu调度的基本单位,而线程使用进程pcb描述实现,并且同一个进程中的所有pcb共用同一个虚拟地址空间,因此相较于传统进程更加的轻量化

请简述LWP与pthread_create创建的线程之间的关系

pthread_create是一个库函数,功能是在用户态创建一个用户线程,而这个线程的运行调度是基于一个轻量级进程实现的。

简述轻量级进程ID与进程ID之间的区别

因为Linux下的轻量级进程是一个pcb,每个轻量级进程都有一个自己的轻量级进程ID(pcb中的pid),而同一个程序中的轻量级进程组成线程组,拥有一个共同的线程组ID

请简述什么是线程互斥,为什么需要互斥

线程互斥指的是在多个线程间对临界资源进行争抢访问时有可能会造成数据二义,因此通过保证同一时间只有一个线程能够访问临界资源的方式实现线程对临界资源的访问安全性

我们将在下一篇讲述生产者消费者的文章中为大家展示这些内容的使用!

  • 17
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

无敌岩雀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值