线程同步(互斥锁、条件变量、读写锁、自旋锁)

线程同步

所谓线程同步,就是有多个线程共享相同的内存时,需要确保共享数据在每个线程中都是一致的。若同一个共享变量在两个及以上线程中所显示的结果是不一样的,就表明线程不同步。

什么情况下需要使用线程同步?
当一个线程可以修改的变量,其它线程也可以读取或修改的时候,必须进行线程同步,否则可能导致错误。

假设有两个线程A和B,需要对整型全局变量i进行递增操作,过程如下所示:

递增操作不是原子操作,编译器完成递增操作实际上需要3条机器指令:
(1)将变量从内存单元读入寄存器;
(2)在寄存器中对变量进行增量操作;
(3)把寄存器中新的值写回到内存单元。

两个线程在进行递增操作时,有可能出现如下图所示情况,
线程同步(互斥锁、条件变量、读写锁、自旋锁)_第1张图片
1、线程A运行,把变量i的值(5)读入到线程A的寄存器中;
2、线程A的寄存器内容进行增量操作,寄存器的值变为6;与此同时,线程B运行,将变量i的值读入到线程B的寄存器中,由于线程A的寄存器的值还未写回内存单元,因此读入到线程B的寄存器的值仍为5。
3、将线程A寄存器的值写回内存单元,变量i的值变为6;与此同时,线程B的寄存器内容进行增量操作,其寄存器的值变为6;
4、将线程B寄存器的值写回内存单元,变量i的值变为6;

从上述步骤可以看出,经过两次递增操作之后,变量i的值并没有从5变为7,而是从5变为6,产生了错误的结果。写两个线程递增一个全局变量的情境验证,如下:

#include
#include

#defeine NUMLOOP 20

int i=0;

void* threadFunc(void* vptr)
{
  int k;
  for (k=0;k<NUMLOOP;k++)
  {
    ++i;
    printf("B %ld:%d\n",pthread_self(),i);
  }
}

int main()
{
  pthread_t tidB;
  pthread_create(&tidB,NULL,&threadFunc,NULL):  //创建子线程B

  //假设主线程为线程A
  for (int k=0;k<NUMLOOP;k++)
  {
    ++i;
    printf("A %ld:%d\n",pthread_self(),i);
  }

  pthread_join(tidB);
  return 0;
}

运行结果:
线程同步(互斥锁、条件变量、读写锁、自旋锁)_第2张图片
从运行结果可以看出,线程A、B同时对全局变量i进行20次递增运算,最后的结果为40,没有出现错误。就算把递增运算次数增大到5000次,发现结果依旧不会出现错误。这又是为什么呢?因为递增运算在计算机内部虽然是有3条机器指令的,但是cpu的处理速度非常之快,很难出现线程A将变量i读入寄存器还没写回内存单元时,线程B就来读取变量i的情况,因此发生错误的概率也极小。

为了强化程序运行出现错误的可能性,简单修改一下上述程序,将递增操作分三步完成,先用一个临时变量temp接收i的值,再显示temp+1的值,再递增变量i,其代码如下:

#include
#include

#defeine NUMLOOP 20

int i=0;

void* threadFunc(void* vptr)
{
  int k,temp;
  for (k=0;k<NUMLOOP;k++)
  {
    temp=i+1;
    printf("B %ld:%d\n",pthread_self(),temp);
    i=temp;
  }
}

int main()
{
  pthread_t tidB;
  pthread_create(&tidB,NULL,&threadFunc,NULL):  //创建子线程B

  //假设主线程为线程A
  int temp;
  for (int k=0;k<NUMLOOP;k++)
  {
    temp=i+1;
    printf("A %ld:%d\n",pthread_self(),temp);
    i=temp;
  }

  pthread_join(tidB);
  return 0;
}

运行结果:
线程同步(互斥锁、条件变量、读写锁、自旋锁)_第3张图片
我们发现结果为29,线程A和B中有多个重复值,其原因就是一个线程将变量i读入寄存器还没写回内存单元时,另一个线程就来读取变量i的情况,从而导致结果出错。

互斥锁

要使上述多线程递增共享变量不会出现错误,就需要保证同一时间只有一个线程访问数据。而互斥锁可以对共享资源进行加锁,在访问完成后再解锁,从而保证同一时间只有一个线程访问共享数据,从而实现线程同步。对共享资源进行加锁之后,任何其他试图访问该资源的线程都会阻塞直到当前线程释放该互斥锁。如果解锁时有一个以上的线程阻塞,那么所有阻塞在该锁上的线程都会变成可运行状态,第一个变为运行状态的线程就可以对该共享资源加锁,其它线程就无法持有锁就只有重新阻塞等待解锁。这样,就能保证每次只有一个线程访问共享资源,从而实现线程同步。

利用互斥锁重新实现多线程递增共享变量,代码如下:

#include
#include

#defeine NUMLOOP 20

int i=0;
pthread_mutex_t mutex_=PTHREAD_MUTEX_INITIALIZER;

void* threadFunc(void* vptr)
{
  int k,temp;
  for (k=0;k<NUMLOOP;k++)
  {
    pthread_mutex_lock(&mutex_);  //加锁
    temp=i+1;
    printf("B %ld:%d\n",pthread_self(),temp);
    i=temp;
    pthread_mutex_unlock(&mutex_); //解锁
  }
}

int main()
{
  pthread_t tidB;
  pthread_create(&tidB,NULL,&threadFunc,NULL):  //创建子线程B

  //假设主线程为线程A
  int temp;
  for (int k=0;k<NUMLOOP;k++)
  {
    pthread_mutex_lock(&mutex_); //加锁
    temp=i+1;
    printf("A %ld:%d\n",pthread_self(),temp);
    i=temp;
    pthread_mutex_unlock(&mutex_);  //解锁
  }

  pthread_join(tidB);
  return 0;
}

多次运行,取其中一次运行结果:
线程同步(互斥锁、条件变量、读写锁、自旋锁)_第4张图片
不管多少次运行,也不管递增次数多大,最后的结果都不会出现错误,这是因为任意一个线程对共享变量i进行操作时,都会给变量加锁,必须等解锁后其它线程才能访问该变量,这就保证了数据安全,也就实现了线程同步。

条件变量

互斥锁是实现线程同步的一种机制,适用于防止多个线程同时访问某个共享变量,但是我们还需要另外某种在等待某个条件发生期间进入睡眠的机制,这就是条件变量。条件变量是实现线程同步的另一个机制,必须与互斥锁一起使用,通常使用在一个线程的行为依赖另一个线程对共享数据状态的改变情况下。

对于生产者消费者问题,生产线程生产产品,消费线程使用产品。若不采用条件变量,消费线程需要不断轮询检查是否有产品,会大大增加cpu的开销。但引入条件变量后,消费线程就可以挂起(wait)直到生产线程有产品时唤醒(sigal)消费线程,大大减少cpu开销。

#include
int pthread_cond_signal(pthread_cond_t *cptr);  //唤醒正在等待的线程
int pthread_cond_wait(pthread_cond_t *cptr,pthread_mutex_t *mptr);  //线程挂起等待唤醒信号
int pthread_cond_timedwait(pthread_cond_t *cptr,pthread_mutex_t *mptr,const struct timespec *abstime); //线程挂起限时等待唤醒信号

将上述案例情境设定为,子线程B递增变量i,次数为20,主线程A判断当i=20时,将i的值乘以2。

用条件变量来实现的代码:

#include
#include

#defeine NUMLOOP 20

int i=0;
pthread_mutex_t mutex_=PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_=PTHREAD_MUTEX_INITIALIZER;

void* threadFunc(void* vptr)
{
  int k,temp;
  for (k=0;k<NUMLOOP;k++)
  {
    pthread_mutex_lock(&mutex_);  //加锁
    temp=i+1;
    printf("B %ld:%d\n",pthread_self(),temp);
    i=temp;
    pthread_mutex_unlock(&mutex_); //解锁
  }
  pthread_cond_signal(&cond_);  //唤醒线程
}

int main()
{
  pthread_t tidB;
  pthread_create(&tidB,NULL,&threadFunc,NULL):  //创建子线程B

  //假设主线程为线程A
  pthread_mutex_lock(&mutex_); //加锁
  while(i!=20)
    pthread_cond_wait(&cond_,&mutex_); //线程阻塞等待唤醒信号
  i=i*2;
  printf("A %ld:%d\n",pthread_self(),i);
  pthread_mutex_unlock(&mutex_);  //解锁


  pthread_join(tidB);
  return 0;
}

运行结果:
线程同步(互斥锁、条件变量、读写锁、自旋锁)_第5张图片
结果无误,其原理也很简单,线程B对变量i递增,线程A阻塞等待唤醒信号,当线程B递增完成后,就会唤醒线程A,A就对变量i进行乘以2的操作。

不知道大家留意没有,为啥条件变量一定要跟互斥锁一起使用呢?

pthread_mutex_lock(&mutex_); //加锁
while(i!=20)
    pthread_cond_wait(&cond_,&mutex_); //线程阻塞等待唤醒信号

在这段代码中,若不加锁,存在如下可能:子线程传出i的值为19,主线程判断19不等于20后,在调用 pthread_cond_wait进行阻塞之前,子线程递增i的值为20。这时i=20这个信息就会丢失,主线程就会永远阻塞在pthread_cond_wait上。

自旋锁:适用于锁持有时间短的情况

互斥锁是通过休眠使线程阻塞,此时cpu会进行线程调度,将阻塞的线程挂起并读入下一个要运行线程的内存内容,等到解锁后,被挂起的线程变为可运行状态,等待cpu调用。当互斥锁持有的时间很短时,线程调度上下文切换的时间远远大于阻塞的时间,此时进行线程调度不是最优方法。自旋锁与互斥锁类似,但其在获取锁之前一直处于忙等阻塞状态。因此,自旋锁通常用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。

读写锁:适用于读操作远大于写的情况

互斥锁只有两种状态:加锁状态和不加锁状态。读写锁有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式下的读写锁。

写模式下加锁:独占式锁,在该锁被解锁之前,所有试图对该锁进行加锁的线程都会被阻塞;
读模式下加锁:共享式锁,所有线程都可以以读模式来访问该锁,但是写模式下的线程都会在获取锁之前阻塞,直到所有读模式下的线程释放读锁;

你可能感兴趣的:(操作系统,操作系统,线程同步,互斥锁)