-
既是重要的理论知识,也是重要的实践知识
-
基于生产者-消费者问题、哲学家就餐问题,为了保护临界资源/进程间更好的进行通信,提出线程同步和进程同步
一、线程同步
1、线程同步之互斥量
1)工作原理-保持了关键操作的原子性
假设线程1为生产者,线程2为消费者,互斥量在其中一个线程占用临界资源时,阻止另一个线程访问临界资源
- 生产者-消费者问题出现的原因:
两个线程的指令交叉执行 - 解决:
互斥量可以保证两个线程先后执行
2)原子性
- 原子性是指一系列操作不可被中断的特性
- 这一系列操作要么全部执行完成,要么全部没有执行
- 不存在部分执行部分未执行的情况
3)互斥量
- 互斥量是最简单的线程同步的方法
- 互斥量(互斥锁),处于两态之一的变量:解锁和加锁
- 在使用临界资源之前,给临界资源加锁,使其他线程不可以抢占资源;直到资源使用完,再给它解锁,其他进程才可以使用
- 两个状态可以保证资源访问的串行
- 操作系统直接提供了互斥量的API
- 开发者可以直接使用API完成资源的加锁、解锁操作
- 操作系统提供的C语言的API:phread_mutex_t
4)例子
不适用互斥量时:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
//临界资源
int num=0;
//生产者
void *producer(void*){
int times=1000000;
while(times--){
num +=1;
}
}
//消费者
void *consumer(void*){
int times=1000000;
while(times--){
num -=1;
}
}
int main(){
printf('Start in main function.');
pthread_t thread1,thread2;
pthread_create(&thread1,NULL,&produceer,NULL);
pthread_create(&thread2,NULL,&comsumer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
printf('Print in main function:num=%d\n',num);
return 0;
}
结果中会出现不等于0的情况,即出错
使用互斥量时:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
//互斥量初始化
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER
//临界资源
int num=0;
//生产者
void *producer(void*){
int times=1000000;
while(times--){
//加锁
pthread_mutex_lock(&mutex);
num +=1;
//解锁
pthread_mutex_unlock(&mutex);
}
}
//消费者
void *consumer(void*){
int times=1000000;
while(times--){
//加锁
pthread_mutex_lock(&mutex);
num -=1;
//解锁
pthread_mutex_unlock(&mutex);
}
}
int main(){
printf('Start in main function.');
pthread_t thread1,thread2;
pthread_create(&thread1,NULL,&produceer,NULL);
pthread_create(&thread2,NULL,&comsumer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
printf('Print in main function:num=%d\n',num);
return 0;
}
增加互斥量后,由于每次执行都需要加锁解锁,因此执行时间增加,但是不会出错了
2、线程同步之自旋锁
1)原理
通互斥量(也叫互斥锁)相同,都是在临界资源被占用时,阻止其他线程再去使用
与互斥锁的不同:自旋锁不会让出CPU,但是互斥锁会
2)自旋锁
死循环等待锁被释放
- 自旋锁也是一张多线程同步的变量
- 使用自旋锁的线程会反复检查锁变量是否可用
- 自旋锁不会让出CPU,是一种忙等待状态
- 操作系统提供的C语言的API为:pthread_spinlock_t
3)自旋锁优点
- 自旋锁避免了进程或线程上下文切换的开销
- 操作系统内部很多地方使用的是自旋锁
- 自旋锁不适合在单核CPU使用(因为它是一种忙等待状态,会影响其他进程使用CPU)
4)例子
未加锁时:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
//临界资源
int num=0;
//生产者
void *producer(void*){
int times=1000000;
while(times--){
num +=1;
}
}
//消费者
void *consumer(void*){
int times=1000000;
while(times--){
num -=1;
}
}
int main(){
printf('Start in main function.');
pthread_t thread1,thread2;
pthread_create(&thread1,NULL,&produceer,NULL);
pthread_create(&thread2,NULL,&comsumer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
printf('Print in main function:num=%d\n',num);
return 0;
}
结果不为0
加锁后:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
//定义自旋锁
pthread_spinlock_t spin_lock;
//临界资源
int num=0;
//生产者
void *producer(void*){
int times=1000000;
while(times--){
//加锁
pthread_spin_lock(&spin_lock);
num +=1;
//解锁
pthread_spin_unlock(&spin_lock);
}
}
//消费者
void *consumer(void*){
int times=1000000;
while(times--){
//加锁
pthread_spin_lock(&spin_lock);
num -=1;
//解锁
pthread_spin_unlock(&spin_lock);
}
}
int main(){
printf('Start in main function.');
//初始化自旋锁
pthread_spin_init(&spin_lock,0);
pthread_t thread1,thread2;
pthread_create(&thread1,NULL,&produceer,NULL);
pthread_create(&thread2,NULL,&comsumer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
printf('Print in main function:num=%d\n',num);
return 0;
}
结果都为0
3、线程同步之读写锁
1)提出背景
与互斥量、自旋锁类似,但又基于一些临界资源问题作出改变
临界资源问题:
- 临界资源多读少些(临界资源存在大量历史数据)
- 读取的时候并不会改变临界资源的值
- 每次读取都会将历史数据读一遍,效率太低
2)读写锁
- 读写锁是一种特殊的自旋锁
- 允许多个读者同时访问资源以提高读性能
- 对于写操作则是互斥的,不允许多个写操作同时访问临界资源
模型:
3)例子
- API接口:pthread_rwlock_t
- 读锁:pthread_rwlock_rdlock
- 写锁:pthread_rwlock_wrlock
- 读锁与写锁互斥,写锁与写锁互斥
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <vector>
//定义读写锁
pthread_rwlock_t rwlock=PTHREAD_RWLOCK_INITIALIZER;
//临界资源
int num=0;
//读操作
void *read(void*){
int times=100000000;
while(times --){
//读操作之前加读锁
pthread_rwlock_rdlock(&rwlock);
if (times % 1000 ==0){
printf('print num in reader:num = %d/n',num);
//sleep10微妙
usleep(10);
}
//读操作结束后解锁
pthread_rwlock_unlock(&rwlock);
}
}
//写操作
void *writer(void*){
int times=100000000;
while(times --){
//写操作前加写锁
pthread_rwlock_wrlock(&rwlock);
num += 1;
//写操作结束后解锁
pthread_rwlock_unlock(&rwlock);
}
}
int main(){
printf('Start in main function.\n');
pthread_t thread1,pthread2,pthread3;
pthread_create(&thread1,NULL,&reader,NULL);
pthread_create(&thread2,NULL,&reader,NULL);
pthread_create(&thread3,NULL,&writer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
pthread_join(thread3,NULL);
printf('Print in main function:num=%d\n',num);
return 0;
}
通过与互斥量的对比,可看到,读写锁可以有效提高效率
4、线程同步之条件变量
1)工作原理
- 条件变量是一种相对复杂的线程同步方法
- 条件变量允许线程睡眠,直到满足执行条件
- 当满足条件时,可以向该线程发出信号,唤醒线程
2)生产者-消费者遗留问题
- 缓冲区小于等于0时,不允许消费者消费,消费者必须等待
- 此时,当生产者生产一个产品时,唤醒可能等待的消费者
- 缓冲区满时,不允许生产者生产,生产者必须等待
- 此时,当消费者消费了一个产品时,唤醒可能等待的生产者
- 这里的唤醒操作即由条件变量完成
3)条件变量的使用
- API接口:pthread_cond_t
- 等待条件满足:pthread_cond_wait
- 等待被唤醒:pthread_cond_signal
- 配合互斥量使用一起完成:pthread_mutex_t
4)例子
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <unistd.h>
#include <pthread.h>
//缓存区
int MAX_BUF=100;
//临界资源
int num=0;
//定义条件变量
pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
//定义互斥量
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
//生产者
void* producer(void*){
while(true){
//加锁
pthread_mutex_lock(&mutex);
while(num>=MAX_BUF){
//等待
pthread_cond_wait(&cond,&mutex);
printf('缓冲区满了,等待消费者消费...\n');
}
num += 1;
printf('生产一个产品,当前产品数量为:%d\n',num);
sleep(1);
//唤醒消费者
pthread_cond_signal(&cond)
printf('通知消费者...\n')
//解锁
pthread_mutex_unlock(&mutex);
sleep(1);//两个sleep用来计算时间和速度
}
}
//消费者
void* consumer(void*){
while(true){
//加锁
pthread_mutex_lock(&mutex);
while(num<=0){
//等待
pthread_cond_wait(&cond,&mutex);
printf('缓冲区空了,等待生产者生产...\n');
}
num -= 1;
printf('消费一个产品,当前产品数量为:%d\n',num);
sleep(1);
//唤醒生产者
pthread_cond_signal(&cond);
printf('通知生产者...\n');
//解锁
pthread_mutex_unlock(&mutex);
sleep(1);
}
}
int main(){
pthread_t thread1,thread2;
pthread_create(&thread1,NULL,&consumer,NULL);
pthread_create(&thread2,NULL,&producer,NULL);
pthread_join(thread1,NULL);
pthread_join(thread2,NULL);
return 0;
}
5、线程同步方法总结
1)互斥量、自旋锁、读写锁
在访问前首先给临界资源加锁, 加锁后其他线程就不能访问临界资源,直到该线程 访问完成后,再对它进行解锁, 之后其他线程才可以访问临界资源
线程想要访问临界资源时,发现临界资源被加锁,这时就等待临界资源被解锁,等到临界资源被解锁后,再对它进行访问,访问前先加锁,访问后要解锁。
2)条件变量
线程拿到临界资源后首先加锁保护条件变量或临界资源,然后判断需要的条件是否满足,如果满足则访问临界资源执行操作;如果不满足,则等待,直到条件满足该线程被唤醒,访问临界资源执行操作,执行结束后,解锁临界资源。
3)四种方法对比
同步方法 | 描述 |
---|---|
互斥锁 | 最简单的一种线程同步方法,会阻塞线程 |
自旋锁 | 避免切换的一种线程同步方法,属于**“忙等待”**,一直占用CPU |
读写锁 | 为**“读多写少”的资源设计的线程同步方法,对于读多写少的线程可以显著提高性能** |
条件变量 | 相对复杂的一种线程同步方法,有灵活的使用场景 |
二、进程同步
0、使用fork系统调用创建进程
1)概念
- fork系统调用是用于创建进程的
- fork创建的进程初始化状态与父进程一样
- 系统会为fork的进程分配新的资源
2)fork函数的调用
- fork系统调用无参数
- fork会返回两次,分别返回子进程id和0(父进程调用fork函数创建子进程,子进程会继承父进程的逻辑、初始状态,因此返回两次,一次是父进程的,一次是子进程的)
- 返回子进程id的是父进程,返回0的是子进程(通过返回的值判断当前是子进程还是父进程)
3)C++演示fork系统调用创建进程
i、验证fork创建进程时返回两次
#include <stdio.h>
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
//定义一个进程变量pid
pid_t pid;
//调用fork函数创建新进程,无需参数
pid=fork();
//对返回值进行判断,输出哪个是父进程,哪个是子进程
if(pid==0){
cout<<'这是一个子进程。'<<endl;
}
else if(pid>0){
cout<<'这是一个父进程。'<<endl;
cout<<'子进程id是:'<<pid<<endl;
}
else if(pid<0){
cout<<'进程创建失败。'<<endl;
}
return 0;
}
如果创建成功,则会出现两个运行结果:既有“这是一个父进程”+子进程id,又有“这是一个子进程”,可见,fork函数创建进程时,确实是返回两次,一次是0,一次是子进程id。
ii、 验证fork创建进程的初始化状态与父进程相同
#include <stdio.h>
#include <iostream>
#include <cstring>
#include <unistd.h>
using namespace std;
int main()
{
//定义一个进程变量pid
pid_t pid;
//定义一个初始变量
int num=888;
//调用fork函数创建新进程,无需参数
pid=fork();
//对返回值进行判断,输出哪个是父进程,哪个是子进程
if(pid==0){
cout<<'这是一个子进程。'<<endl;
cout<<'num in son process:'<<num<<endl;
while(true){
num += 1;
cout<<'num in son process:'<<num<<endl;
sleep(1);
}
}
else if(pid>0){
cout<<'这是一个父进程。'<<endl;
cout<<'子进程id是:'<<pid<<endl;
cout<<'num in father process:'<<num<<endl;
while(true){
num -= 1;
cout<<'num in father process:'<<num<<endl;
sleep(1);
}
}
else if(pid<0){
cout<<'进程创建失败。'<<endl;
}
return 0;
}
如果代码编译正确,从结果可以看到,开始时,父进程和子进程中的num均为888,随着进程的执行,子进程和父进程各自走向自己的进程,所以fork函数创建的子进程与父进程的初始状态是相同的
1、进程同步之共享内存
1)知识点回顾
- 进程的线程共享进程资源
- 进程共享计算机资源
- 每个进程都有自己的进程空间
- 每个进程空间使用段页式管理方法通过页表与物理内存建立起之间的映射关系
- 进程与进程的进程空间是相互独立且互不干扰的
2)进程空间
- 某种程度上,多进程是共同使用物理内存的
- 由于操作系统的进程管理,进程间的内存空间是独立的
- 进程默认是不能访问进程空间之外的内存空间的
- 但是根据共享内存可以
- 通过在进程1和进程2的页表中都指向同一个物理地址来实现
3)共享内存
- 共享存储允许不相关的进程访问同一片物理内存
- 实现原理:将同一片物理内存映射到不同进程页表中,使不同进程可以通过页表来访问同一个物理内存
- 共享内存是两个进程之间共享和传递数据最快的方式
- 共享内存未提供同步机制,需要借助其他机制管理访问,从而避免并发访问带来的问题
- 共享内存是高性能后台开发中最常用的进程同步方式
4)使用共享内存的步骤
- 申请共享空间
- 将共享内存连接到进程空间
- 进程使用共享内存
- 共享内存脱离进程空间&删除
5)例子
客户端和服务端通过共享内存通信
分别创建server、client、common三个代码实现
common.h
#ifndef _COMMON_N_
#define _COMMON_H_
// 定义字符串最大长度为2048
#define TEXT_LEN 2048
// 共享内存的数据结构
// 默认内存中存储的数据是没有结构的,定义一些结构,将数据存储进去
struct ShmEntry{
// 是否可以读取共享内存,用于进程间同步
bool can read;
// 共享内存信息
char msg[2048];
};
#endif
server.cpp
#include "commn.h"
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <iostream>
int main()
{
//定义共享内存的结构体
struct ShmEntry *entry;
//1、向操作系统申请共享内存
// 使用shmget函数,参数有key_t,sizeof,返回内存id
int shmid=shmget((key_t)1111,sixeof(struct ShmEntry),0666|IPC_CREAT);
//如果返回shmid=-1,表示申请失败
if(shmid==-1){
std::cout<<'Creat share memory error!'<<std::endl;
return -1;
}
//2、连接到当前进程空间--使用共享内存
//使用shmat()函数连接,参数为(shmid,0,0),结合共享内存结构体实例化一个共享内存
entry=(ShmEntry*)shmat(shmid,0,0);
//entry->can_read=0表示共享内存中可读取的部分为0,即共享内存不可用
entry->can_read=0;
while(true){
if(entry->can_read==1){
std::cout<<'Received message:'<<entry->msg<<std::endl;
entry->can_read=0;
}else{
std::cout<<'Entry can not read.Sleep 1s.'<<std::endl;
sleep(1);
}
}
//3、共享内存脱离进程空间
//使用shmdt()函数
shmdt(entry);
//4、删除共享内存
shmctl(shmid,IPC_RMID,0);
return 0;
}
client.cpp
#include "commn.h"
#include <sys/shm.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <iostream>
int main()
{
//定义共享内存的结构体
struct ShmEntry *entry;
//1、向操作系统申请共享内存
// 使用shmget函数,参数有key_t,sizeof,返回内存id
int shmid=shmget((key_t)1111,sixeof(struct ShmEntry),0666|IPC_CREAT);
//如果返回shmid=-1,表示申请失败
if(shmid==-1){
std::cout<<'Creat share memory error!'<<std::endl;
return -1;
}
//2、连接到当前进程空间--使用共享内存
//使用shmat()函数连接,参数为(shmid,0,0),结合共享内存结构体实例化一个共享内存
entry=(ShmEntry*)shmat(shmid,0,0);
//entry->can_read=0表示共享内存不可读,则可写
entry->can_read=0;
char buffer[TEXT_LEN];
while(true){
if(entry->can_read==0){
std::cout<<'Input message>>>';
fgets(buffer,TEXT_LEN,stdin);
strncpy(entry->msg,buffer,TEXT_LEN);
std::cout<<'Send message:'<<entry->msg<<std::endl;
entry->can_read=1;
}
}
//3、共享内存脱离进程空间
//使用shmdt()函数
shmdt(entry);
//4、删除共享内存
shmctl(shmid,IPC_RMID,0);
return 0;
}
2、进程同步之Unix域套接字
1)域套接字
- 域套接字是一种高级的进程间通信的方法
- Unix域套接字可以用于同一机器进程间通信
- 套接字(socket)原是网络通信中使用的术语
- Unix系统提供的域套接字提供了网络套接字类似的功能,包括可靠性
- 共享内存需要额外的机制同步多个进程间的通信,Unix系统不需要额外的机制同步多个进程间的通信
2)服务端如何使用域套接字
- 创建套接字
- 绑定(bind)套接字
- 监听(listen)套接字
- 接收&处理信息
3)客户端如何使用套接字
- 创建套接字
- 连接套接字
- 发送信息
4)实例
使用域套接字连接客户端和服务端
server.cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un,h>
#include <strings.h>
#include <string.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <unistd.h>
#include <iostream>
//定义套接字的路径,定义的套接字文件用来连接服务端和客户端
#define SOCKET_PATH "/.domainsocket"
#define MSG_SIZE 2048
int main()
{
int socket_fd,accept_fd;
int ret=0;
socklen_t addr_len;
char msg[MSG_SIZE];
struct sockaddr_un server_addr;
//1、创建域套接字
socket_fd=socket(PF_UNIX,SOCK_STREAM,0);
if(-1==socket_fd){
std::cout<<'Socket create failed!'<<std::endl;
return -1;
}
//移除已有域套接字路径
remove(SOCKET_PATH);
//内存区域置0
bzero(&server_addr,sizeof(server_addr));
server_addr.sun_family=PF_UNIX;
strcpy(server_addr.sun_path,SOCKET_PATH);
//2、绑定域套接字
std::cout<<'Binding socket...'<< std::endl;
ret=bind(socket_fd,(sockaddr*)&server_addr,sizeof(server_addr));
if(-1==ret){
std::cout<<'Bind socket failed.'<<std::ebdl;
return -1;
}
//3、监听套接字
std::cout<<'Listening socket...'<<std::endl;
//10表示最大能监听的客户端
ret=listen(socket_fd,10);
if(-1==ret){
std::cout<<'Listen failed.'<<std::endl;
return -1;
}
std::cout<<'Waiting for new requests.'<<std::endl;
//4、接收来自客户端的请求
accept_fd=accept(socket_fd,NULL,NULL);
bzero(msg,MSG_SIZE);
while(true){
//循环接收处理接收到的信息
recv(accept_fd,msg,MSG_SIZE,0);
std::coyt<<'Received message from remote:'<<msg<<std::endl;
}
close(accept_fd);
close(socket_fd);
return 0;
}
client.cpp
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un,h>
#include <strings.h>
#include <string.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <unistd.h>
#include <iostream>
//定义套接字的路径,定义的套接字文件用来连接服务端和客户端
#define SOCKET_PATH "/.domainsocket"
#define MSG_SIZE 2048
int main()
{
int socket_fd;
int ret=0;
char msg[MSG_SIZE];
struct sockaddr_un server_addr;
//1、创建域套接字
socket_fd=socket(PF_UNIX,SOCK_STREAM,0);
if(-1==socket_fd){
std::cout<<'Socket create failed!'<<std::endl;
return -1;
}
/内存区域置0
bzero(&server_addr,sizeof(server_addr));
server_addr.sun_family=PF_UNIX;
strcpy(server_addr.sun_path,SOCKET_PATH);
//2、连接域套接字
ret=connect(socket_fd,(sockaddr*)&server_addr,sizeof(server_addr));
if(-1==ret){
std::cout<<'Connect socket failed.'<<std::ebdl;
return -1;
}
//用户输入信息,键盘获取信息,并将信息发送给服务端
while(true){
std::cout<<'Input message>>>';
fgets(msg,MSG_SIZE,stdin);
//发送信息
ret=send(socket_fd,msg,MSG_SIZE,0);
}
close(socket_fd);
return 0;
}
先运行server,再运行client。
5)域套接字的优缺点
- 提供了单机简单可靠的进程通信同步服务
- 只能在单机使用,不能跨机器使用