course2610_lab12 多线程中共享变量的操作

1. 多线程时, 共享变量加一

#include "stdio.h"
#include "stdlib.h"
#include "pthread.h"
#include "errno.h"
#include "unistd.h"


// 定义线程数目 为16;
#define PTHREAD_NUM 16

unsigned long sum = 0;

void *thread(void *arg){
    for (int i = 0;  i < 10000; i++)
        sum += 1;  // 注意,此处没有使用原子操作
}


int main(void){

    printf("before ..sum = %lu \n", sum);

    pthread_t  pthread[PTHREAD_NUM]; // 被创建线程的标识
    int ret;        // 接受返回值
    void *retval[PTHREAD_NUM];

    for(int i = 0; i < PTHREAD_NUM; i++){

        ret = pthread_create(&pthread[i], NULL, thread, NULL);
        if(ret != 0){
            perror("cause:");
            printf("cause pthread %d failed. \n", i+1);
        }
    }

    for( int i = 0; i < PTHREAD_NUM; i++)
        pthread_join(pthread[i], &retval[i]);

    printf("after.... sum = %lu \n", sum);

    return 0;

}

在这个代码中,函数thread()有一个循环,做了简单的加1操作。
在主函数中,调用pthread_create()创建了一个线程, 线程中执行加一的操作10000
这个步骤执行16次,总共创建了16个线程,

运行这个程序,我们本来的期望结果是16*10000,

编译 , 执行上述代码

~/Documents/course_2610/lab12_multi_thread$ gcc sharedVar_exp1.c -o sharedVar -lpthread


:~/Documents/course_2610/lab12_multi_thread$ ./sharedVar 
before ..sum = 0 
after.... sum = 32509 

但是发现结果不是160000,那到底为什么?
如何解决?

2. 多线程并发执行, 共享变量加1 的情况

在这里插入图片描述

在这里插入图片描述

现在你明白为什么计算结果不对了么

3. 原子操作的概念

加1操作实际上是由汇编指令的三条指令完成的,如果这三条指令在执行期间不被中断,一口气执行完,那就不出错了,因此,就有了原子操作的概念;

所谓原子操作,就是在执行期间不可分割,要么全部执行,要么一条也不执行。

__sync_fetch_and_add系列一共有十二个函数,
有加/减/与/或/异或/等函数的原子性操作函数,

__sync_fetch_and_add
顾名思义,先fetch,然后自加,返回的是自加以前的值。

以count = 4为例,调用__sync_fetch_and_add(&count,1),之后,返回值是4,然后,count变成了5.

__sync_fetch_and_add,自然也就有__sync_add_and_fetch,如,__sync_add_and_fetch()是GCC内嵌的函数,可以进行加1的原子操作;
这个的意思就很清楚了,先自加,在返回。

他们哥俩的关系与i++和++i的关系是一样的。

对于多线程对全局变量进行自加,我们就再也不用理线程锁了。

在Linux下如何进行原子操作? gcc从4.1.2开始提供了__sync_*系列的build-in函数,用于提供加减和逻辑运算的原子操作,其声明如下:

下面是这群函数的全家福,大家看名字就知道是这些函数是干啥的了。
在用gcc编译的时候要加上选项 -march=i686

type __sync_fetch_and_add (type *ptr, type value);
type __sync_fetch_and_sub (type *ptr, type value);
type __sync_fetch_and_or (type *ptr, type value);
type __sync_fetch_and_and (type *ptr, type value);
type __sync_fetch_and_xor (type *ptr, type value);
type __sync_fetch_and_nand (type *ptr, type value);
type __sync_add_and_fetch (type *ptr, type value);
type __sync_sub_and_fetch (type *ptr, type value);
type __sync_or_and_fetch (type *ptr, type value);
type __sync_and_and_fetch (type *ptr, type value);
type __sync_xor_and_fetch (type *ptr, type value);
type __sync_nand_and_fetch (type *ptr, type value);

...

下面这行代码,和上面被 pthread_mutex 保护的那行代码作用是一样的,而且也是线程安全的。

__sync_fetch_and_add( &global_int, 1 );

下面这两个函数,可以轻松实现互斥锁的功能。


bool __sync_bool_compare_and_swap (type*ptr, type oldval, type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval,  type newval, ...)

这两个函数提供原子的比较和交换,如果ptr == oldval,就将newval写入ptr,
第一个函数在相等并写入的情况下返回true.
第二个函数在返回操作之前的值。

4. 临界资源与临界区

临界资源:系统中一次只允许一个进程(线程)访问的资源称为临界资源。一旦分配给进程,不能强制剥夺。 临界区:并发执行的进程中, 访问(读取或修改)临界资源必须互斥执行的程序段叫临界区。临界区分散在并发执行的进程中。例如:
在这里插入图片描述

在上面 线程A和线程B的例子中,谁是临界资源,谁是临界区?

5. 互斥对象的加锁保护

最简单的处理办法就是对互斥对象 进行加锁保护。

如何使用被称为互斥对象的灵巧小玩意,来保护线程代码中共享数据结构的完整性。

5.1 互斥对象

使用互斥对象解决共享变量加一的问题:

在前面的小节中 ,谈到了会导致异常结果的线程代码。
两个线程分别对同一个全局变量进行了二十次加一。变量的值最后应该是 40,但最终值却是 21。

这是怎么回事呢?因为一个线程不停地“取消”了另一个线程执行的加一操作,所以产生这个问题。现在让我们来查看改正后的代码,它使用 互斥对象(mutex)来解决该问题:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <errno.h>
#include "unistd.h" 
int myglobal;
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
void *thread_function(void *arg)
{
     int i,j;
     for ( i=0; i<20; i++)
     {
          pthread_mutex_lock(&mymutex);
          j=myglobal; j=j+1;
          printf(".");
          fflush(stdout);
          sleep(1);
          myglobal=j;
          pthread_mutex_unlock(&mymutex);
     }
     return NULL;
}
int main(void)
{
     pthread_t mythread;
     int i;
     if ( pthread_create( &mythread, NULL, thread_function, NULL) )
     {
          printf("error creating thread.");
          abort();
     }
     for ( i=0; i<20; i++)
     {
          pthread_mutex_lock(&mymutex);
          myglobal=myglobal+1;
          pthread_mutex_unlock(&mymutex);
          printf("o");
          fflush(stdout);
          sleep(1);
     }
     if ( pthread_join ( mythread, NULL ) )
     {
          printf("error joining thread.");
          abort();
     }
     printf("\nmyglobal equals %d\n",myglobal);
     exit(0);
}

如果将这段代码与 前一篇文章 中给出的版本作一个比较,就会注意到增加了 pthread_mutex_lock() pthread_mutex_unlock() 函数调用。在线程程序中这些调用执行了不可或缺的功能。他们提供了一种 相互排斥的方法(互斥对象即由此得名)。两个线程不能同时对同一个互斥对象加锁。

  • 互斥对象的工作原理。

如果线程 a 试图锁定一个互斥对象,而此时线程 b 已锁定了同一个互斥对象时,线程 a 就将进入睡眠状态。一旦线程 b 释放了互斥对象(通过 pthread_mutex_unlock()调用),线程 a 就能够锁定这个互斥对象(换句话说,线程 a 就将从 pthread_mutex_lock() 函数调用中返回,同时互斥对象被锁定)。同样地,当线程 a 正锁定互斥对象时,如果线程 c 试图锁定互斥对象的话,线程 c 也将临时进入睡眠状态。对已锁定的互斥对象上调用 pthread_mutex_lock() 的所有线程都将进入睡眠状态,这些睡眠的线程将“排队”访问这个互斥对象。

5.2 线程加锁

通常使用pthread_mutex_lock()pthread_mutex_unlock() 来保护数据结构。这就是说,通过线程的锁定和解锁,对于某一数据结构,确保某一时刻只能有一个线程能够访问它。可以推测到,当线程试图锁定一个未加锁的互斥对象时,POSIX 线程库将同意锁定,而不会使线程进入睡眠状态。

图中,锁定了互斥对象的线程能够存取复杂的数据结构,而不必担心同时会有其它线程干扰。那个数据结构实际上是“冻结”了,直到互斥对象被解锁为止。

pthread_mutex_lock() pthread_mutex_unlock()函数调用,如同“在施工中”标志一样,将正在修改和读取的某一特定共享数据包围起来。
这两个函数调用的作用就是警告其它线程,要它们继续睡眠并等待轮到它们对互斥对象加锁。当然,除非在 每个 对特定数据结构进行读写操作的语句前后,都分别放上 pthread_mutex_lock() pthread_mutext_unlock()调用,才会出现这种情况。

听上去很有趣,但究竟为什么要让线程睡眠呢?要知道,线程的主要优点不就是其具有独立工作、更多的时候是同时工作的能力吗?是的,确实是这样。然而,每个重要的线程程序都需要使用某些互斥对象。让我们再看一下示例程序以便理解原因所在。

5.3 线程解锁与运行

请看 thread_function(),循环中一开始就锁定了互斥对象,最后才将它解锁。在这个示例程序中,mymutex 用来保护 myglobal 的值。仔细查看 thread_function(),加一代码把 myglobal 复制到一个局部变量,对局部变量加一,睡眠一秒钟,在这之后才把局部变量的值传回给 myglobal

不使用互斥对象时,即使主线程在 thread_function() 线程睡眠一秒钟期间内对 myglobal 加一,thread_function() 苏醒后也会覆盖主线程所加的值。使用互斥对象能够保证这种情形不会发生。(您也许会想到,我增加了一秒钟延迟以触发不正确的结果。把局部变量的值赋给 myglobal 之前,实际上没有什么真正理由要求 thread_function() 睡眠一秒钟。)使用互斥对象的新程序产生了期望的结果:

:~/Documents/course_2610/lab12_multi_thread$ gcc  mutex_exp1.c -o mutex -lpthread


:~/Documents/course_2610/lab12_multi_thread$ ./mutex
o....................ooooooooooooooooooo
myglobal equals 40

为了进一步探索这个极为重要的概念,让我们看一看程序中进行加一操作的代码:

thread_function() 加一代码: j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=j; 主线程加一代码: myglobal=myglobal+1;

如果代码是位于单线程程序中,可以预期 thread_function() 代码将完整执行。接下来才会执行主线程代码(或者是以相反的顺序执行)。在不使用互斥对象的线程程序中,代码可能(几乎是,由于调用了 sleep() 的缘故)以如下的顺序执行:

thread_function() 线程 
主线程 j=myglobal; 
j=j+1; 
printf("."); 
fflush(stdout); 
sleep(1); 
myglobal=myglobal+1;
myglobal=j;

当代码以此特定顺序执行时,将覆盖主线程对 myglobal 的修改。程序结束后,就将得到不正确的值。如果是在操纵指针的话,就可能产生段错误。注意到 thread_function() 线程按顺序执行了它的所有指令。看来不象是thread_function()有什么次序颠倒。问题是,同一时间内,另一个线程对同一数据结构进行了另一个修改。

5.4 线程的工作机制

在解释如何确定在何处使用互斥对象之前,先来深入了解一下线程的内部工作机制。请看第一个例子:

假设主线程将创建三个新线程:线程 a、线程 b 和线程 c。假定首先创建线程 a,然后是线程 b,最后创建线程 c。

pthread_create( &thread_a, NULL, thread_function, NULL);

pthread_create( &thread_b, NULL, thread_function, NULL);

pthread_create( &thread_c, NULL, thread_function, NULL);
在第一个 pthread_create() 调用完成后,可以假定线程 a 不是已存在就是已结束并停止。第二个 pthread_create() 调用后,主线程和线程 b 都可以假定线程 a 存在(或已停止)。

然而,就在第二个 create() 调用返回后,主线程无法假定是哪一个线程(a 或 b)会首先开始运行。虽然两个线程都已存在,线程 CPU 时间片的分配取决于内核和线程库。至于谁将首先运行,并没有严格的规则。尽管线程 a 更有可能在线程 b 之前开始执行,但这并无保证。对于多处理器系统,情况更是如此。如果编写的代码假定在线程 b 开始执行之前实际上执行线程 a 的代码,那么,程序最终正确运行的概率是 99%。或者更糟糕,程序在您的机器上 100% 地正确运行,而在您客户的四处理器服务器上正确运行的概率却是零。

从这个例子还可以得知,线程库保留了每个单独线程的代码执行顺序。换句话说,实际上那三个pthread_create()调用将按它们出现的顺序执行。从主线程上来看,所有代码都是依次执行的。有时,可以利用这一点来优化部分线程程序。例如,在上例中,线程 c 就可以假定线程 a 和线程 b 不是正在运行就是已经终止。它不必担心存在还没有创建线程 a 和线程 b 的可能性。可以使用这一逻辑来优化线程程序。

现在来看另一个假想的例子。假设有许多线程,他们都正在执行下列代码:

myglobal=myglobal+1;
那么,是否需要在加一操作语句前后分别锁定和解锁互斥对象呢?也许有人会说“不”。编译器极有可能把上述赋值语句编译成一条机器指令。大家都知道,不可能"半途"中断一条机器指令。即使是硬件中断也不会破坏机器指令的完整性。基于以上考虑,很可能倾向于完全省略 pthread_mutex_lock() pthread_mutex_unlock() 调用。不要这样做。

废话吗?不完全是这样。
首先,不应该假定上述赋值语句一定会被编译成一条机器指令,除非亲自验证了机器代码。即使插入某些内嵌汇编语句以确保加一操作的完整执行――甚至,即使是自己动手写编译器!-- 仍然可能有问题。

答案在这里。
使用单条内嵌汇编操作码在单处理器系统上可能不会有什么问题。每个加一操作都将完整地进行,并且多半会得到期望的结果。

但是多处理器系统则截然不同。在多 CPU 机器上,两个单独的处理器可能会在几乎同一时刻(或者,就在同一时刻)执行上述赋值语句。不要忘了,这时对内存的修改需要先从 L1 写入 L2 高速缓存、然后才写入主存。(SMP 机器并不只是增加了处理器而已;它还有用来仲裁对 RAM 存取的特殊硬件。)最终,不确定在写入主存的竞争中,哪个 CPU 将会"胜出"。

要产生可预测的代码,应使用互斥对象。互斥对象将插入一道"内存关卡",由它来确保对主存的写入按照线程锁定互斥对象的顺序进行。

考虑一种以 32 位块为单位更新主存的 SMP 体系结构。如果未使用互斥对象就对一个 64 位整数进行加一操作,整数的最高 4 位字节可能来自一个 CPU,而其它 4 个字节却来自另一 CPU。糟糕吧!最糟糕的是,使用差劲的技术,您的程序在重要客户的系统上有可能不是很长时间才崩溃一次,就是早上三点钟就崩溃。David R. Butenhof 在他的《POSIX 线程编程》(请参阅本文末尾的 参考资料部分)一书中,讨论了由于未使用互斥对象而将产生的种种情况。

5.5 互斥对象与并发的权衡

如果放置了过多的互斥对象,代码就没有什么并发性可言,运行起来也比单线程解决方案慢。如果放置了过少的互斥对象,代码将出现奇怪和令人尴尬的错误。幸运的是,有一个中间立场。首先,互斥对象是用于串行化存取共享数据。不要对非共享数据使用互斥对象,并且,如果程序逻辑确保任何时候都只有一个线程能存取特定数据结构,那么也不要使用互斥对象。

其次,如果要使用共享数据,那么在读、写共享数据时都应使用互斥对象。用 pthread_mutex_lock() pthread_mutex_unlock() 把读写部分保护起来,或者在程序中不固定的地方随机使用它们。学会从一个线程的角度来审视代码,并确保程序中每一个线程对内存的观点都是一致和合适的。为了熟悉互斥对象的用法,最初可能要花好几个小时来编写代码,但是很快就会习惯并且不必多想就能够正确使用它们。

5.6 互斥对象的使用方法

现在该来看看使用互斥对象的各种不同方法了。让我们从初始化开始。在 thread3.c 示例 中,我们使用了静态初始化方法。这需要声明一个 pthread_mutex_t 变量,并赋给它常数 PTHREAD_MUTEX_INITIALIZER

pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
很简单吧。但是还可以动态地创建互斥对象。当代码使用 malloc() 分配一个新的互斥对象时,使用这种动态方法。此时,静态初始化方法是行不通的,并且应当使用例程 pthread_mutex_init()

int pthread_mutex_init( pthread_mutex_t *mymutex, const pthread_mutexattr_t *attr)
正如所示,pthread_mutex_init 接受一个指针作为参数以初始化为互斥对象,该指针指向一块已分配好的内存区。第二个参数,可以接受一个可选的 pthread_mutexattr_t 指针。这个结构可用来设置各种互斥对象属性。但是通常并不需要这些属性,所以正常做法是指定 NULL

一旦使用 pthread_mutex_init() 初始化了互斥对象,就应使用 pthread_mutex_destroy() 消除它。pthread_mutex_destroy() 接受一个指向 pthread_mutext_t 的指针作为参数,并释放创建互斥对象时分配给它的任何资源。请注意,pthread_mutex_destroy()不会 释放用来存储 pthread_mutex_t 的内存。释放自己的内存完全取决于您。还必须注意一点,pthread_mutex_init() pthread_mutex_destroy() 成功时都返回零。

pthread_mutex_lock(pthread_mutex_t *mutex)
pthread_mutex_lock() 接受一个指向互斥对象的指针作为参数以将其锁定。如果碰巧已经锁定了互斥对象,调用者将进入睡眠状态。函数返回时,将唤醒调用者(显然)并且调用者还将保留该锁。函数调用成功时返回零,失败时返回非零的错误代码。

pthread_mutex_unlock(pthread_mutex_t *mutex)
pthread_mutex_unlock() 与 pthread_mutex_lock() 相配合,它把线程已经加锁的互斥对象解锁。始终应该尽快对已加锁的互斥对象进行解锁(以提高性能)。并且绝对不要对您未保持锁的互斥对象进行解锁操作(否则,pthread_mutex_unlock() 调用将失败并带一个非零的 EPERM 返回值)。

pthread_mutex_trylock(pthread_mutex_t *mutex)
当线程正在做其它事情的时候(由于互斥对象当前是锁定的),如果希望锁定互斥对象,这个调用就相当方便。调用 pthread_mutex_trylock() 时将尝试锁定互斥对象。如果互斥对象当前处于解锁状态,那么您将获得该锁并且函数将返回零。然而,如果互斥对象已锁定,这个调用也不会阻塞。当然,它会返回非零的 EBUSY 错误值。然后可以继续做其它事情,稍后再尝试锁定。

5.7 互斥对象的局限性

互斥对象是线程程序必需的工具,但它们并非万能的。例如,如果线程正在等待共享数据内某个条件出现,那会发生什么呢?代码可以反复对互斥对象锁定和解锁, 以检查值的任何变化。同时,还要快速将互斥对象解锁,以便其它线程能够进行任何必需的更改。这是一种非常可怕的方法,因为线程需要在合理的时间范围内频繁 地循环检测变化。

在每次检查之间,可以让调用线程短暂地进入睡眠,比如睡眠三秒钟,但是因此线程代码就无法最快作出响应。真正需要的是这样一种方法,当线程在等待满足某些 条件时使线程进入睡眠状态。一旦条件满足,还需要一种方法以唤醒因等待满足特定条件而睡眠的线程。如果能够做到这一点,线程代码将是非常高效的,并且不会 占用宝贵的互斥对象锁。这正是 POSIX 条件变量能做的事!

POSIX 条件变量将是主题,其中将说明如何正确使用条件变量。

到那时,您将拥有了创建复杂线程程序所需的全部资源,那些线程程序可以模拟工作人员、 装配线等等。

回顾

实验报告,深切体会如下概念, 结合概念 根据自己的理解进行表达, 可以通过举例或者比喻说明

1)什么是原子操作?

所谓原子操作,就是在执行期间不可分割,要么全部执行,要么一条也不执行。

2)Linux的线程如何确保原子操作?

gcc从4.1.2开始提供了__sync_*系列的build-in函数,用于提供加减和逻辑运算的原子操作,其声明如下:

type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
...
比如,__sync_add_and_fetch()是GCC内嵌的函数,可以进行加1的原子操作

3)什么是临界资源?
临界资源:系统中一次只允许一个进程(线程)访问的资源称为临界资源。一旦分配给进程,不能强制剥夺。

4)什么是临界区?

临界区:并发执行的进程中, 访问(读取或修改)临界资源必须互斥执行的程序段叫临界区。临界区分散在并发执行的进程中。

5)Linux线程如何确保临界区互斥访问?

对 互斥对象 进行加锁保护,

reference
Multithreaded simple data type access and atomic variables

  • 1
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值