线程同步、进程同步、互斥量、信号量、条件变量等

1. 进程和线程同步机制

1.1. 线程同步

  • 线程同步机制是编程语言为多线程运行制定的一套规则,合理地运用这些规则可以很大程度上保障程序的正确运行。
  • 这套机制包含两方面的内容
    • 一是关于多线程间的数据访问的规则,关乎程序运行的正确与否,是相当重要的内容;
    • 二是多线程间活动的规则。很大程度上是影响程序的运行效率

1.1.1. 临界区CriticalSections

  • 只能用于进程间的线程同步
  • 临界区是Windows下的的概念
#include <windows.h>

CRITICAL_SECTION cs;//定义临界区对象
InitializeCriticalSection(&cs);//初始化临界区
EnterCriticalSection(&cs);//进入临界区
LeaveCriticalSection(&cs);//离开临界区
DeleteCriticalSection(&cs);//删除临界区

1.1.2. 互斥量Mutex(互斥锁)

  • 互斥器的功能和临界区域很相似,在linux下的概念
  • Mutex所花费的时间比 Critical Section 多的多,但是 Mutex 是核心对象(Event、Semaphore也是),可以跨进程使用,而且等待一个被锁住的Mutex可以设定 TIMEOUT,不会像Critical Section那样无法得知临界区域的情况,而一直死等
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr); /*初始化函数*/
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);/*去初始化函数*/

int pthread_mutex_lock(pthread_mutexattr_t *attr)/*加锁*/
int pthread_mutex_unlock(pthread_mutexattr_t *attr)/*解锁*/

1.1.3. 事件通知机制Event

  • 事件(Event)是 windows(WIN32) 提供的最灵活的线程间同步方式
  • 一个事件有两种状态:激发状态和未激发状态。也称有信号状态和无信号状态
  • 事件使用最多的情况是, 当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,
  • 有两种不同类型的事件对象。一种是手动重置的事件,另一种是自动重置的事件。
    • 手动重置事件被设置为激发状态后,会唤醒所有等待的线程,而且一直保持为激发状态,直到程序重新把它设置为未激发状态。
    • 自动重置事件被设置为激发状态后,会唤醒“一个”等待中的线程,然后自动恢复为未激发状态。
  • 如果跨进程访问事件,必须对事件命名,在对事件命名的时候,要注意不要与系统命名空间中的其它全局命名对象冲突;
  • 由于event对象属于内核对象,故进程B可以调用OpenEvent函数通过对象的名字获得进程A中event对象的句柄,然后将这个句柄用于ResetEvent、SetEvent和WaitForMultipleObjects等函数中。此法可以实现一个进程的线程控制另一进程中线程的运行
#include <windows.h>
#include <iostream>
 
using namespace std;
int number = 1;
HANDLE hEvent;//定义事件句柄
 
unsigned long __stdcall ThreadProc1(void* lp) {
    while (number < 100) {
        WaitForSingleObject(hEvent, INFINITE);
        cout << "thread 1 : " << number << endl;
        ++number;
        Sleep(100);
        SetEvent(hEvent);
    }
    return 0;
}
 
unsigned long __stdcall ThreadProc2(void* lp) {
    while (number < 100) {
        WaitForSingleObject(hEvent, INFINITE);
        cout << "thread 2 : " << number << endl;
        ++number;
        Sleep(100);
        SetEvent(hEvent);
    }
    return 0;
}

// 素材结果不固定,从0~100,thread1 和 thread2 交替输出
int main() {
    CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
    CreateThread(NULL, 0, ThreadProc2, NULL, 0, NULL);
 
    hEvent = CreateEvent(NULL, false, true, (LPCTSTR)"event");
    
    Sleep(10 * 1000);
    system("pause");
    return 0;
}

1.1.4. 信号量Semaphore

  • 信号量是最具历史的同步机制, 是 Dijkstra 在1965年提出的一种同步的方案。
  • 使用一种特殊的被称作信号量的整形结构来记录某一临界区操作的次数。
  • 它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。
  • 信号量是非负整型变量,除了初始化之外,它只能通过两个标准原子操作:wait(semap) , signal(semap) ; 来进行访问;
  • 操作也被成为PV原语(P来源于荷兰语proberen"测试",V来源于荷兰语verhogen"增加",P表示通过的意思,V表示释放的意思)
    • P操作时,首先检查其值是否大于0,如果大于0,则将其值减一之后返回进行后续操作,如果值小于等于0,则该进程将进行阻塞
    • V操作时,首先将信号量的值加一,然后检查信号量的值是否大于0,如果大于0,则直接返回执行其他的操作,否则唤醒一个等待在该信号量上的进程。
function P(semaphore &s) {
    s.value--;
    if(s.value < 0){
        wait(s.list)
    }
}

function V(semaphore &s) {
    s.value++;
    if(s.value <= 0){
        wake(s.list);
    }
}
  • 信号量可以同时用来进行互斥和同步的操作,通过信号量赋予不同的初始值,可以使用信号量模拟互斥锁的行为。

  • 信号量通常是在内核中实现的。Linux环境中,有三种类型

    • Posix(可移植性操作系统接口)有名信号量(使用Posix IPC名字标识)
    • Posix基于内存的信号量(存放在共享内存区中)
    • System V信号量(在内核中维护)。这三种信号量都可用于进程间或线程间的同步。
  • 信号量与互斥量之间的区别

    • 互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。
      • 互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
      • 同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
    • 在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源
  • 互斥量值只能为0/1,信号量值可以为非负整数。
    也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。

  • 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

#include<semaphore>
#include<iostream>
#include<chrono>
#include<thread>

using namespace std;

// 其他常用函数
// sm.try_acquire 尝试减少内部计数器而不阻塞
// sm.try_acquire_for 尝试减少内部计数器,至多阻塞一段时长
// sm.try_acquire_until 尝试减少内部计数器,阻塞直至一个时间点常量
// sm.max 返回内部计数器的最大可能值
std::counting_semaphore sm(0);           //初始化信号量为0

void threadproc() {
	sm.acquire();     //获取资源 ,相当于P操作
	cout << "thread signal" << endl;
	std::this_thread::sleep_for(std::chrono::microseconds(100));
	cout << "thread end" << endl;
	sm.release(1);    //释放资源,相当于V操作
}

int main() {
	thread t1(threadproc);
	cout << "main begain send signal" << endl;
	sm.release(1);
	std::this_thread::sleep_for(std::chrono::milliseconds(200));
	sm.acquire();
	cout << "mian end" << endl;
	t1.join();
	return 0;
}

1.1.5. 读写锁

  • 读模式下加锁状态、写模式下加锁状态、不加锁状态。
  • 在读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。
  • 当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须阻塞直到所有的线程释放读锁。但当读写锁处于读模式锁住状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

int x = 0;
//创建读写锁变量
pthread_rwlock_t myrwlock;

void* read_thread(void* args){
    printf("------%u read_thread ready\n",pthread_self());
    while (1) {
        sleep(1);
        //请求读锁
        pthread_rwlock_rdlock(&myrwlock);
        printf("read_thread: %u,x=%d\n", pthread_self(), x);
        sleep(1);
        //释放读写锁
        pthread_rwlock_unlock(&myrwlock);
    }
    return NULL;
}

void* write_thread(void* param)
{
    printf("------%u write_thread ready!\n",pthread_self());
    while (1) {
        sleep(1);
        // 请求写锁
        pthread_rwlock_wrlock(&myrwlock);
        ++x;
        printf("write_thread: %u,x=%d\n", pthread_self(), x);
        sleep(1);
        //释放读写锁
        pthread_rwlock_unlock(&myrwlock);
    }
    return NULL;
}

int main() {
    int i;
    //初始化读写锁
    pthread_rwlock_init(&myrwlock, NULL);
    //创建 3 个读 x 变量的线程
    pthread_t readThread[3];
    for (i = 0; i < 3; ++i) {
        pthread_create(&readThread[i], NULL, read_thread, NULL);
    }
    //创建 1 个修改 x 变量的线程
    pthread_t writeThread;
    pthread_create(&writeThread, NULL, write_thread, NULL);
    //等待各个线程执行完成
    pthread_join(writeThread, NULL);

    for (int i = 0; i < 3; ++i) {
        pthread_join(readThread[i], NULL);
    }
    //销毁读写锁
    pthread_rwlock_destroy(&myrwlock);
    return 0;
}

1.1.6. 条件变量(condition)

  • 条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
  • 条件变量一般都有wait()和signal()两个函数
    • wait()函数在未接受到任何信号的嘶吼将会阻塞当前线程
    • signal()函数负责唤醒等待在当前条件变量上的线程。注意signal函数具体唤醒多少线程并没有一个特定的限制,其依赖于具体的实现而定。
  • 不同于互斥锁,自旋锁和信号量,等待在某个条件变量上的线程将完全依赖于signal()函数发出的信号而唤醒。一旦发出的信号丢失,该阻塞进程将再也没有机会被唤醒了。
  • 因此条件变量一般都需要配合互斥锁或者信号量使用,一般情况下等待线程的逻辑如下
#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread() {
    // Wait until main() sends data
    std::unique_lock<std::mutex> lk(m);
    //子进程的中wait函数对互斥量进行解锁,同时线程进入阻塞或者等待状态。
    cv.wait(lk, []{return ready;});
 
    // after the wait, we own the lock.
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // Send data back to main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // Manual unlocking is done before notifying, to avoid waking up
    // the waiting thread only to block again (see notify_one for details)
    lk.unlock();
    cv.notify_one();
}
 
int main() {
    std::thread worker(worker_thread);
 
    data = "Example data";
    // send data to the worker thread
    {
        //主线程堵塞在这里,等待子线程的wait()函数释放互斥量。
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // wait for the worker
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

1.1.7. 自旋锁

  • 自旋锁可以理解成为“空转锁”。相比于互斥锁而言,自旋锁一旦加锁失败,其线程并不会被调度挂起进入阻塞状态,而是阻塞在一个空循环上。
  • 这种性质决定了自旋锁节省了线程切换的开销,却浪费了CPU时间。

1.1.8. 其他概念

  • 可重入性(Reentrancy):一个线程在其持有一个锁的时候能否再次(或多次)申请该锁
  • CAS(Compare and Swap)
    • 是对一种处理器指令的称呼,不少多线程相关的准库类的实现最终都会借助CAS,实际中大多数情况不会直接使用。
    • 对于简单的自增操作count++来说,使用锁的开销过大,使用volatile又不能保证原子性,这种情况下可以使用CAS。
    • 它能将read-modify-write和check-and-act之类操作转换为原子操作

1.2. 进程同步

进程间通信IPC

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

1.2.1. 信号量

参考线程同步

1.2.2. 信号Signal

  • 信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,直到该进程恢复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。
  • Linux系统中常用信号:
    • SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
    • SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
    • SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\键将产生该信号。
    • SIGBUS和SIGSEGV:进程访问非法地址。
    • SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
    • SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
    • SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号。
    • SIGALRM:定时器信号。
    • SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。
  • 信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,,信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件
  • 信号事件主要有两个来源:
    • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
    • 软件终止:终止进程信号、其他进程调用kill函数、软件异常产生信号。
  • 信号生命周期和处理流程
    • 信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;
    • 操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号。
    • 目的进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后在回复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度。

1.2.3. 管道

  • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
  • 管道的缓冲区是有限的(管道制存在于内存中,在管道创建时,为缓冲区分配一个页面大小)
  • 匿名管道
    • 只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系
  • 有名管道
    • 它允许无亲缘关系进程间的通信。
  • 单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在与内存中。
  • 数据的读出和写入(先入先出):一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。
  • 管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据,管道一端的进程顺序的将数据写入缓冲区,另一端的进程则顺序的读出数据。
  • 该缓冲区可以看做是一个循环队列,读和写的位置都是自动增长的,不能随意改变,一个数据只能被读一次,读出来以后在缓冲区就不复存在了。
  • 当缓冲区读空或者写满时,有一定的规则控制相应的读进程或者写进程进入等待队列,当空的缓冲区有新数据写入或者满的缓冲区有数据读出来时,就唤醒等待队列中的进程继续读写。

1.2.4. 消息队列

  • 消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示。

  • 与管道不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。

  • 另外与管道不同的是,消息队列在某个进程往一个队列写入消息之前,并不需要另外某个进程在该队列上等待消息的到达

  • 消息队列特点总结

    • 消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识.
    • 消息队列允许一个或多个进程向它写入与读取消息.
    • 管道和消息队列的通信数据都是先进先出的原则。
    • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比FIFO更有优势。
    • 消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。
    • 目前主要有两种类型的消息队列:POSIX消息队列 以及 System V 消息队列,System V 消息队列目前被大量使用。System V 消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。

1.2.5. 共享内存

  • 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。
  • 共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。
  • 它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
  • Linux支持的主要三种共享内存方式:mmap()系统调用、Posix共享内存,以及System V共享内存实践

1.2.6. 文件

独占方式打开,也可以间接实现数据同步

1.2.7. 套接字socket通信

  • 可用于不同机器间的进程通信
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值