多线程间的通信和同步

最近看了很多关于网络编程和多线程的书,为了以后查看相关内容方便,整理了几本书的精华形成这篇博文,希望能帮助观看这篇博文的读者。

目录

一、什么是多线程?

二、为什么要创建线程

三、线程之间如何通信

四、线程安全

五、线程的同步

(一)互斥量mutex

(二)临界区 critical section

(三)信号量 semaphore

(四)事件 event


一、什么是多线程?

再说多线程之前引入多进程的概念。那什么是进程?在 Windows 系统中,我们每开一个应用程序系统就为其开辟一个进程,就比如我们打开一个 Word 文档就是一个进程,如果再此基础上按 control + N 在新建个 Word 文档这就开了两个进程。

其中每个进程的内存空间都有保存全局变量的“数据区”、像 malloc / new 等函数的动态分配提供空间的堆(Heap)、函数运行时使用的栈(Stack)构成。每个进程都拥有这样的独立空间,多个进程的内存结构可以参考下图。

多线程间的通信和同步_第1张图片

但如果以获得多个代码执行流为主要目的,就不行该这样分离内存结构,而只需要分离栈区域。这样可以有如下优点:

  • 上下文切换时(这里指进程间的切换)不需要切换数据区和堆
  • 可以利用数据区和堆交换数据

实现以上目地的方法就是多线程,就像我们打开一个 Word 文档,在里面同时编辑 sheet1,sheet2 一样,每一个 sheet 就是一个线程。线程为了保持多条代码执行流而隔离开了栈区域,因此具有如下图的结构:

多线程间的通信和同步_第2张图片

二、为什么要创建线程

通过上面的讲解我们知道了,多线程是能够在一个应用程序中进行多个任务。比如我们要打印 1000 页的Word,如果只有一个线程,那么我们在打印结束前是不可以对 Word 进行操作的,而且打印1000 页要耗费很多时间。但是,实际并不是如此,我们在打印的时候依然可以对 Word 进行编辑操作,这就是多线程的一种应用,处理耗时程序。同样的应用还用在数据采集当中。等

三、线程之间如何通信

在 Windows 系统中线程之间的通信有两种方式

  • 使用全局变量进行通信
  • 使用自定义消息进行通信

在第一部分中我们介绍了,线程的数据区和堆区域是共享的,所以我们可以声明全局变量来进行线程之间的通信和数据交换。如果线程之间传递的数据比较复杂,我们可以定义一个结构,通过传递指向该结构的指针进行消息传递。接着让线程监视这个变量,当这个变量符合一定的条件时,表示该线程终止。

使用自定义消息暂时不做解释,是 Windows 编程中MFC 的内容,如果有读者和我同样学习 MFC 请在文章下面留言,我在补充相关内容。下面给出基于 Linux 系统的代码。

//
//  main.cpp
//  thread
//
//  Created by 刘一丁 on 2019/6/15.
//  Copyright © 2019年 LYD. All rights reserved.
//  本示例用的是 Linus 系统的编程语言,如有需要 Windows 编程语言的请在博文中留言,博主在补齐。
//  本示例存在线程间通信的安全问题,即同时访问同一存储区变量(临界区),解决这个问题可以用线程间的同步
//  函数功能:通过两个线程分别计算 1-5、6-10 的和,并返回其值

#include 
#include 

void *thread_summation(void *arg);         //声明线程
int sum = 0;                               //线程间通信用的全局变量

int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    int range1[] = {1, 5};
    int range2[] = {6, 10};
    
    pthread_create(&id_t1, nullptr, thread_summation, (void*)range1);    //创建线程
    pthread_create(&id_t2, nullptr, thread_summation, (void*)range2);
    
    pthread_join(id_t1, NULL);                                           //控制线程的执行流
    //调用该函数的线程将进入等待状态,直到第一个参数 ID 的线程终止为止。
    pthread_join(id_t2, NULL);
    
    return 0;
}

void *thread_summation(void *arg)
{
    int start = ((int*)arg)[0];
    int end = ((int*)arg)[1];
    
    while(start <= end)
    {
        sum += start;             //这里设计到对 sum 值的访问
        start++;
    }
    return NULL;
}

流程图如下所示:

多线程间的通信和同步_第3张图片

四、线程安全

在第三部分我已经提出了示例中存在的临界区问题,该问题的发生是有概率的,和电脑系统配置有关,运行结果可能因机器而异。那么怎么产生的这个问题呢?

上述示例中两个线程同时访问变量 sum,这里的访问指的是对 sum 的值的更改。除此之外例如,对于像磁盘驱动器这样独占性系统资源,由于线程可以执行进程的任何代码段,且线程的运行是由系统调度自动完成的,具有一定的不确定性,因此就有可能出现两个线程同时对磁盘驱动器进行操作,从而出现上面的错误。再比如,对于银行系统的计算机来说,可能使用一个线程来更新其用户数据库,而用另外一个线程来读取数据库以响应储户需求,极有可能读数据库的线程读取的是未完全更新的数据库,因为可能在读的时候只有一部分数据被更新过。具体解释涉及到 CPU 和内存管理,再此略,如果感兴趣的读者,可以自行查找相关文献书籍。

那么怎么解决这个问题呢?

线程间的同步就可以解决这个问题。

五、线程的同步

使隶属于同一进程的线程协调一致的工作就是线程间的同步。在多线程环境里,需要对线程进行同步。常用的同步对象有临界区(Critical Section)、互斥(Mutex)、信号量(Semaphore)和事件(event)等。用于解决线程访问顺序引发的问题。需要同步的情况可以从以下两方面考虑:

  • 同时访问同一内存空间时发生的情况。
  • 需要指定访问同一内存空间的线程执行顺序的情况。
支持多线程同步的同步类
类型 说明
Critical Section 当在一个时间内仅有一个线程被允许修改数据或其某些其他控制资源时使用,用于保护共享资源(比如写数据)
Mutex 当多个应用(多个进程)同时存取相应资源时使用,用于保护共享资源
Semaphore 一个应用允许同时有多个线程访问相应资源时使用(比如读数据),主要功能用于资源计数
event 某个线程必须等待某些事件发生后才能存取相应资源时使用,以协调线程之间的动作。

(一)互斥量mutex

下面介绍互斥量的使用方法(基于 Windows,Linux 系统下步骤也是一样的)

  • 定义  CMutex 类的一个全局对象(以使各个线程均能访问),如

CMutex mutex;

  • 在访问临界区之前,调用 mutex 类的成员 Lock()获得临界区

mutex.Lock();

在线程中调用该函数来使线程获得它所请求的临界区。如果此时没有其他线程占有临界区,则调用 Lock()的线程获得临界区;否则,线程即将挂起,并放入到一个系统队列中等待,直到当前拥有临界区的线程释放了临界区时为止。

  • 在本线程中访问临界区中的共享资源
  • 访问临界区完毕后,使用CMutex 的成员函数 UnLock()来释放临界区。

mutex.UnLock();

对于 Linux 系统来说直接给出函数使用过程

  • 声明 mutex 全局变量
  • 创建互斥量
  • 获得临界区
  • 访问共享资源
  • 释放互斥量

下面给出 Linux 系统下上面示例的改进版,请读者自行分析,pthread_mutex_lock();放在 while 循环里面和 while 循环外面的区别,如果有兴趣可以在博文下留言讨论。

//
//  main.cpp
//  thread
//
//  Created by 刘一丁 on 2019/6/15.
//  Copyright © 2019年 LYD. All rights reserved.
//  本示例用的是 Linus 系统的编程语言,如有需要 Windows 编程语言的请在博文中留言,博主在补齐。

#include 
#include 

void *thread_summation(void *arg);         //声明线程
int sum = 0;                               //线程间通信用的全局变量
pthread_mutex_t mutex;                     //声明 mutex 变量

int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    int range1[] = {1, 5};
    int range2[] = {6, 10};
    
    pthread_mutex_init(&mutex, NULL);       //创建互斥量
    
    pthread_create(&id_t1, nullptr, thread_summation, (void*)range1);    //创建线程
    pthread_create(&id_t2, nullptr, thread_summation, (void*)range2);
    
    pthread_join(id_t1, NULL);                                           //控制线程的执行流
    //调用该函数的线程将进入等待状态,直到第一个参数 ID 的线程终止为止。
    pthread_join(id_t2, NULL);
    
    printf("result: %d\n", sum);
    pthread_mutex_destroy(&mutex);           //释放互斥量
    return 0;
}

void *thread_summation(void *arg)
{
    int start = ((int*)arg)[0];
    int end = ((int*)arg)[1];
    
    while(start <= end)
    {
        pthread_mutex_lock(&mutex);          //获得临界区
        sum += start;                        //访问共享资源
        start++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

//output:
//result: 55

讨论:互斥量中的 Lock() 是怎么工作的?从以下 3个方面来解释。

1.内核对象

操作系统创建的资源(Resource)有很多种,如进程、线程、文件及刚刚介绍的互斥量和即将介绍的临界区、信号量等。不同资源类型在“管理”方式上也有差异。例如,文件管理中应注册并更新文件相关的数据 I/O 位置、文件的打开方式(read or write)等。如果是线程,则应注册并维护线程 ID、线程所属进程等信息。操作系统为了以记录相关信息的方式管理各种资源,在其内部生成数据块(相当于结构体)。当然,每种资源需要维护的信息不同,所以每种资源拥有的数据块格式也不相同。这类数据块称为“内核对象”。

2.内核对象的两种状态

资源类型不同,内核对象也含有不同的信息。其中,应用程序实现过程中需要特别关注的信息被赋予某种“状态”(state)。例如,线程内核对象中需要重点关注线程是否已经终止,所以终止状态又称“signaled 状态”(其他线程可访问),未终止状态成为“non-signaled 状态”(其他线程不可访问)。同时,操作系统会在进程或线程终止时,把相应的内核对象改为 signaled 状态。

3.互斥量内核的状态

在基于互斥量的同步中将创建互斥量 mutex对象。与其他同步对象相同,它是进入临界区的一把“钥匙”。因此,为了进入临界区,需要得到mutex 对象这把钥匙(Lock)。相反离开时需要上交 mutex 对象(unlock)。

互斥量被某一线程获取时(Lock)为 non-signaled 状态,释放时(unlock)进入 signaled 状态。因此,可以使用 Lock 和 unlock 来验证互斥量是否已经被分配。Lock函数的调用结果有如下2种。

  • 调用后进入阻塞状态:互斥量对象已被其他线程获取,现处于 non-signaled 状态。
  • 调用后直接返回:其他线程未占用互斥量对象,现处于signaled 状态。

(二)临界区 critical section

临界区的使用规则和互斥量完全相同,在这里不在讨论,二者的区别也在上文表格中体现,一个用在线程之间,一个用除了线程还可以用在进程之间。

(三)信号量 semaphore

信号量的使用方法也和互斥量相同,步骤是一样的。所以博主在这里介绍信号量的另一种使用方法“二进制信号量”,又称为“控制线程顺序”的同步方法。

在 Windows 系统下信号量和互斥量唯一区别的就是构造函数,其他用法一样,下面给出 Windows 系统下 semaphore 的构造函数

1.CSemaphore(LONG lInitialCount = 1,
           LONG lMaxCount = 1,
           LPCTSTR pstrName = NULL,
           LPSECURITY_ATTRIBUTES lpsaAttributes = NULL);

→lInitialCount - 信号量对象的初始计数值,即可访问线程数目的初始值
→lMaxCount - 信号量对象计数值的最大值,该参数决定了同一时刻可访问由信号量保护的资源的线程最大数目

2.BOOL ReleaseSemaphone(HANDLE hSemaphone, LONG lReleaseCount, LPLONG lpPreviousCount);

→成功时返回 TRUE,失败时返回 FALSE
→hSemaphone - 传递需要释放的信号量对象
→lReleaseCount -  释放意味着信号量值的增加,通过该参数可以指定增加的值。
                 超过最大值则不增加,返回 FALSE
→lpPreviousCount - 用于保存修改之前值的变量地址,不需要时可传递 NULL。

注:信号量对象的值大于 0 时成为 signaled 状态,为 0 时成为 non-signaled 状态。
因此,调用 WaitForSingleObject 函数时,信号量大于 0 的情况才会返回。
返回的同时将信号量值减 1,同时进入 non-signaled 状态(当然,仅限于信号量减 1 后等于 0 的情况)。


执行 WaitForSingleObject(hSemaphone, INFINITE);时-1
执行 ReleaseSemaphone()时+1,为 0 时阻塞

WaitForSingleSemaphone(hSemaphone, INFINITE)
//临界区的开始
//..........
//临界区的结束
ReleaseSemaphone(hSemaphone, 1, NULL);

在 Linux 系统下,首先给出相当于互斥量 Lock、UnLock 的函数。

#include 

int sem_post(sem_t * sem);   //+1
int sem_wait(sem_t * sem);   //-1,为0 时阻塞

成功时返回 0,失败时返回其他值。
sem - 传递保存信号量读取值的变量地址,传递给 sem_post 时信号量增 1,传递给 sem_wait 时信号量减 1.

调用sem_init 函数(略)时,操作系统将创建信号量对象,此对象中记录着“信号量值”整数。该值在调用 sem_post 函数时增 1,在调用 sem_wait函数时减 1。但信号量的值不能小于 0,因此,在信号量为 0 的情况下调用 sem_wait 函数时,调用函数的线程将进入阻塞状态(因为函数未返回)。当然,此时如果有其他函数线程调用 sem_post 函数,信号量的值将变为 1,而原本阻塞的线程可以将该信号量重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1)。

sem_wait(&sem);    //信号量变为 0.
//临界区的开始
//.......
//临界区结束
sem_post(&sem);    //信号量变为 1.

上述代码结构中,调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,这种特性就是“二进制信号量”。下面给出基于 Linux 系统的代码示例。

//
//  main.cpp
//  thread
//
//  Created by 刘国栋 on 2019/6/15.
//  Copyright © 2019年 LGD. All rights reserved.
//  本示例用的是 Linus 系统的编程语言,如有需要 Windows 编程语言的请在博文中留言,博主在补齐。
//  本示例实现“线程 A从用户输入得到值后存入全局变量 num,此时线程 B 取走该值并累加。该过程共进行
//  5 次,完成后输出总和并推出程序”

#include 
#include 
#include 

void * read(void * arg);
void * accu(void * arg);
static sem_t sem_one;
static sem_t sem_two;
static int num;

 int main(int argc, const char * argv[])
{
    pthread_t id_t1, id_t2;
    sem_init(&sem_one, 0, 0);
    sem_init(&sem_two, 0, 1);
    
    pthread_create(&id_t1, NULL, read, NULL);
    pthread_create(&id_t2, NULL, accu, NULL);
    
    pthread_join(id_t1, NULL);
    pthread_join(id_t2, NULL);
    
    sem_destroy(&sem_one);
    sem_destroy(&sem_two);
    return 0;
}

void * read(void * arg)
{
    for(int i = 0; i < 5; i++)
    {
        fputs("Input num: ", stdout);
        
        sem_wait(&sem_two);
        scanf("%d", &num);
        sem_post(&sem_one);
    }
    return NULL;
}

void * accu(void * arg)
{
    int sum = 0;
    for(int i = 0; i < 5; i++)
    {
        sem_wait(&sem_one);
        sum += sum;
        sem_post(&sem_two);
    }
    printf("Result: %d\n", sum);
    return NULL;
}

/*
运行结果:semaphore.c
root@my_linux:/tcpip# gcc semaphore.c -D_REENTRANT -o sema -lpthread
root@my_linux:/tcpip# ./sema
Input num:1
Input num:2
Input num:3
Input num:4
Input num:5
Result: 15
*/

上述代码请读者自行分析,有疑问可以在博文下方留言,博主会为其解答。还请读者特别注意分析 24-25 行 44-46、56-58 行代码的使用方式和所达到“二进制信号量”功能的实现。

(四)事件 event

事件同步对象与前2 种同步方法相比有很大不同,区别就在于,该方式下创建对象时,可以在自动以 non-signaled 状态运行的 auto-reset 模式和与之相反的 manual-reset 模式汇总任选其一。而事件对象的主要特点是可以创建 manual-reset 模式的对象。

在 Windows 环境下,介绍创建事件对象的函数。

#include 

HANDLE CreateEvent(
    LPSECURITY_ATTRIBUTS lpEventAttributes, BOOL bManualReset,
    BOOL bIntialState, LPCTSTR lpName);

→成功时返回创建的事件对象句柄,失败时返回 NULL
→lpEventAttributes  安全配置相关参数,采用默认安全时传入 NULL
→bManualReset  传入 TRUE 时创建 manual-reset 模式的事件对象,传入 FALSE 时创建auto-reset 模式的事件对象。
→bIntialState  传入 TURE 时创建 signaled 状态的事件对象,传入 FALSE 时创建 non-signaled 状态的事件对象。
→lpName  用于命名事件对象。传递 NULL 时创建无名的事件对象

在介绍一个函数 WaitForSingleObject 函数,该函数针对单个内核对象验证 signaled状态。

#include 

DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);

→成功时返回事件信息,失败时返回 WAIT——FAILED
→hHandle  查看状态的内核对象句柄。
→dwMilliseconds  以 1/1000 秒为单位指定超时,传递 INFINTE 时函数不会返回,直到内核对象变成 signaled 状态。
→返回值  进入 signaled 状态返回 WAIT_OBJECT_0,超时返回 WAIT_TIMEOUT。

上面这个函数其实就相当于 Lock 的功能,只有当要查看对象的状态为 signaled 时才会有返回值(超时也会返回),否则一直等待(阻塞)。同时该函数由于发生时间(变为signaled状态)返回时,有时会把相应内核对象再次改为 non-signaled 状态。这种可以再次进入 non-signaled 状态的内核对象称为“auto-reset 模式”的内核对象,而不会自动跳转到 non-signaled 状态的内核对象称为“ manual-reset模式”的内核对象。

就如创建实现对象的初始化函数 CreateEvent 的第二个参数。传入 TURE 时创建 manual-reset 模式的事件对象,此时即使 WaitForSingleObject 函数返回也不会回到 non-signaled 状态。因此,需要通过下面两个函数明确更改对象状态。

#include 

BOOL ResetEvent(HANDLE hEvent);  //to the non-signaled
BOOL SetEvent(HANDLE hEvent);    //to the signaled

→成功时返回 TURE,失败时返回 FALSE

所以,传递事件对象句柄并希望改为 non-signaled状态时,应调用 ResetEvent 函数。如果希望改为signaled 状态,则可以调用 SetEvent 函数。下面给出基于 Windows 的示例

//
//  main.cpp
//  thread
//
//  Created by 刘国栋 on 2019/6/19.
//  Copyright © 2019年 LGD. All rights reserved.
//
//  示例中的两个线程同时等待输入字符串

#include 
#include 
#include 
#define STR_LEN 100

unsigned WINAPI NumberOfA(void *arg);
unsigned WINAPI NumberOfOthers(void *arg);

static char str[STR_LRN];
static HANDLE hEvent;

int main(int argc, const char *srgv[])
{
    HANDLE hThread1, hThread2;
    hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
    hThread1 = (HANDLE)_beginthreadex(NULL, 0, NumberOfA, NULL, 0, NULL);
    hThread2 = (HANDLE)_beginthreadex(NULL, 0, NumberOfOthers, NULL, 0, NULL);
    
    fputs("input string: ", stdout);
    fgets(str, STR_LEN, stdin);
    SetEvent(hEvent);
    
    WaitForSingleObjevt(hThread1, INFINITE);
    WaitForSingleObjevt(hThread2, INFINITE);
    ResetEvent(hEvent);
    CloseHandle(hEvent);
    return 0;
}

unsigned WINAPI NumberOfA(void *arg)
{
    int i, cnt = 0;
    WaitForSingleObjevt(hEvent, INFINITE);
    for(i = 0; str[i] != 0; i++)
        if(str[i] == 'A')
            cnt++;
    printf("Num of A: %d \n", cnt);
    return 0;
}

unsigned WINAPI NumberOfOthers(void *arg)
{
    int i, cnt = 0;
    WaitForSingleObjevt(hEvent, INFINITE);
    for(i = 0; str[i] != 0; i++)
        if(str[i] != 'A')
            cnt++;
    printf("Num of Others: %d \n", cnt);
    return 0;
}

//output
//Input string: ABCDABC
//Num of A: 2
//Num of others: 5

→第 24 行:以 non-signaled 状态创建manual-reset 模式的事件对象。

→第 25、26 行:创建两个线程,NumOfA  and  NumOfOthers 同时开始执行,分别执行到 42 行 53 行进入阻塞状态。

→第 30 行:读入字符串后将事件对象改为signaled 状态。第 42、53 行中正在等待的2个线程将摆脱等待状态,开始执行。2 个线程之所以可以同时摆脱等待状态是因为事件对象仍处于signaled 状态。

→第 32、33 行:注意此处的 WaitForSingleObject 传递的句柄是线程的句柄,只有等到线程返回时,该线程的句柄才会由 non-signaled 状态编程 signaled 状态,WaitForSingleObject 才会返回,否则处于阻塞状态。

→第 34 行:虽然在本例子中该语句没太大必要,但还是把事件对象的状态改为 non-signaled。如果不进行明确更改,对象将继续停留在 signaled 状态。

你可能感兴趣的:(#,网络编程,#,多线程编程)