目录
一、关于进程间通信
二、管道
pipe函数
管道的特点
匿名管道
命名管道
mkfifo
三、system v共享内存
shmget函数(创建)
ftok函数(生成key)
shmctl函数(删除)
shmat/dt函数(挂接/去关联)
四、初识信号量
一、关于进程间通信
首先我们都知道,进程运行是具有独立性的,所以两个进程想通信的话,难度较大
进程间通信的本质:
让不同的进程看到同一份资源(内存空间)
这里的能看到的同一块内存,不能属于任何一个进程,而应该是共享的
进程间通信的目的:
为了交互数据、控制、通知等目的
进程间通信的发展:
管道
system V进程间通信
POSIX进程间通信
进程间通信分类:
管道:匿名管道pipe、命名管道
system V IPC:system V 消息队列、共享内存、信号量
POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量、读写锁
二、管道
管道是非常古老的进程间通信的形式
管道是计算机通信领域的设计者设计得一种单向通信的方式
从一个进程连接到另一个进程的一个数据流称为一个管道
管道通信的背后是进程间通过管道进行通信的
下面举个管道的例子,方便理解:
pipe函数
需要用到pipe创建管道,man查看pipe,pipe是系统调用接口
需要的头文件是unistd.h
作用是创建管道
参数是pipefd[2],是输出型参数,希望通过调用它,得到被打开的文件fd
返回值:创建成功返回0,失败返回-1
代码如下:
#include <iostream>
#include <unistd.h>
#include <assert.h>
#include <string>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>using namespace std;int main()
{// 创建管道int pipefd[2] = {0};// 文件描述符0/1/2分别是stdin/stdout/stderr// 这里的pipefd[0]为3,表示读端// 这里的pipefd[1]为4,表示写端int n = pipe(pipefd);assert(n != -1);// debug下assert起作用,release下不起作用// 所以需要(void)n,表示n被使用过// 如果不(void)n,在release下会被认为n没有被使用(void)n;// 创建子进程pid_t id = fork();assert(id != -1);if (id == 0){// 子进程 -> 读// 构建单向通信的信道,父进程写入,子进程读取// 关闭子进程不需要的fd,关闭写close(pipefd[1]);char buffer[1024]; // 缓冲区,用于读数据while (true) // 一直循环{// ssize_t类型是long intssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1);if (s > 0) // 读成功{// read是系统调用,所以读取完结尾不会有\0,自己加buffer[s] = 0;cout << "子进程[" << getpid() << "]获得父进程的message," << buffer << endl;}}// 最后关闭读close(pipefd[0]);exit(0);}// 父进程 -> 写// 构建单向通信的信道,父进程写入,子进程读取// 关闭父进程不需要的fd,关闭读close(pipefd[0]);int count = 0; // 发送消息的条数string message = "父进程正在发送消息";char send_buffer[1024];while (true){// 构建变化的字符串// 往send_buffer中写入snprintf(send_buffer, sizeof(send_buffer), "%s[%d] : %d",message.c_str(), getpid(), count++);// 写入write(pipefd[1], send_buffer, strlen(send_buffer));// 每次sleep方便观察sleep(1);}pid_t ret = waitpid(id, nullptr, 0);assert(ret > 0);(void)ret;// 最后关闭写close(pipefd[1]);return 0;
}
观察运行结果:
通过结果可以发现,父进程每次写入后,子进程都能读取到,以管道的方式实现了进程间通信
管道的特点
1、管道是用来进行具有血缘关系的进程进行进程间通信的,常用于父子进程
2、管道通过让进程间协同,提供了访问控制
访问控制就是指:如果父进程写的慢,例如上述代码,每隔一秒写一次,子进程即使没有sleep,也只能每隔一秒再读;而如果写的很快,当把缓冲区写满了,在读取前也就不能再继续写了
3、管道提供的是面向流式的通信服务(面向字节流,需要定制协议进行数据区分,后面博客会说到)
流式服务:如果写的很快,读的很慢,读的时候一次就可以读一批消息
4、管道是基于文件的,文件的生命周期是随进程的,所以管道的生命周期也是随进程的
写入的一方的fd如果没有关闭,读取的一方有数据就读,没有数据就等
写入的一方的fd如果关闭了,读取的一方read返回0,表示读到了文件的结尾
5、管道是单向通信的,就是半双工通信的一种特殊情况
半双工通信就是指两个人通信,我发你就不能发,你发我就不能发
下面总结四种情况:
①写端快,读端慢,写满就不能再写了
②写端慢,读端快,管道没有数据时,读端必须等待
③写端关闭fd,读端read返回0,表示读到了文件结尾
④读端关闭fd,写端继续写,OS会自动终止写端
前两种也就是上面提到的访问控制
匿名管道
下面引入一个例子了解匿名管道的使用,一个父进程有5个子进程,父进程与每一个子进程都建立对应的管道,每个子进程内部都有处理任务的方法,如果用户给了一个任务,父进程可以给子进程派发该任务,让子进程完成该任务,也就是实现一个小型的进程池
首先实现Makefile:
ProcPool:ProcPool.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -f ProcPool
ProcPool.cc(.cc和.cpp一样)代码:
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cassert>
#include <vector>#include "task.hpp"using namespace std;#define PROCESS_NUM 5int waitcommand(int waitfd, bool& q)
{//uint32_t是4字节uint32_t command = 0;//从文件描述符waitfd中读取,读取的内容写到command里ssize_t s = read(waitfd,&command,sizeof(command));if(s == 0){q = true;return -1;}assert(s == sizeof(uint32_t));return command;
}//给一个进程id,通过文件描述符fd,发送命令command
void sendAndExec(pid_t id,int fd, uint32_t command)
{write(fd,&command,sizeof(command));cout << "调用进程[" << id << "] 执行:`" << desc[command] << endl;
}int main()
{//将task中的方法装载进来load();//pair键值对:pid、pipefdvector<pair<pid_t, int>> slots;//创建多个进程for(int i= 0; i < PROCESS_NUM; ++i){//创建管道int pipefd[2] = {0};int n = pipe(pipefd);assert(n == 0);//这里的(void)n与前一个例子一样的作用//release下,assert就没用了,n会被认为没有使用(void)n;pid_t id = fork();assert(id != -1);//子进程进行读取if(id == 0){//子进程关闭写端close(pipefd[1]);while(true){//false表示不退出bool q = false;//子进程等命令,不发命令,就阻塞int command = waitcommand(pipefd[0], q);//执行对应的命令if(command >= 0 && command < tasksize()){//执行call中command对应的方法call[command]();}else{cout << "非法command: " << command << endl;}}exit(1);}//父进程关闭读端close(pipefd[0]);//存每个子进程的pidslots.push_back(pair<pid_t,int>(id,pipefd[1]));}//父进程派发任务//让数据源更加随机srand((unsigned long)time(nullptr) ^ getpid() ^ 89745213L);while(true){//下面屏蔽的部分是自动选择任务,不需要人为输入//选择任务//int command = rand() % tasksize();//选择进程//int selectID = rand() % slots.size();//将任务交给子进程//sendAndExec(slots[selectID].first,slots[selectID].second,command);//sleep(1);int num;int command;cout << "##########################################" << endl;cout << "##### 1.展示功能 2.发送命令 #####" << endl;cout << "##########################################" << endl;cout << "请输入你的选择:";cin >> num;if(num == 1)show();else if(num == 2){cout << "请选择你的命令:";//选择任务cin >> command;//选择进程int selectID = rand()%slots.size();//将任务交给子进程sendAndExec(slots[selectID].first,slots[selectID].second,command);}}//关闭fd,子进程会退出for(const auto& e : slots){close(e.second);}//等待子进程退出,回收子进程信息for(const auto& e : slots){//默认在阻塞状态去等待子进程waitpid(e.first,nullptr,0);}return 0;
}
Task.hpp
(以hpp结尾,.cc的实现代码混入.h头文件当中,定义与实现都包含在同一文件)
#pragma once#include <iostream>
#include <string>
#include <unistd.h>
#include <vector>
#include <functional>
#include <unordered_map>//这里表示func这个函数类型,返回值都是void,没有参数
typedef std::function<void()> func;
//call中存各种func类型的函数
std::vector<func> call;
//desc是描述各个命令编号,所对应的任务名称的
std::unordered_map<int, std::string> desc;void execadd()
{std::cout << "程序[" << getpid() << "] 执行加法操作的任务" << std::endl;
}void execsub()
{std::cout << "程序[" << getpid() << "] 执行减法操作的任务" << std::endl;
}void execmul()
{std::cout << "程序[" << getpid() << "] 执行乘法操作的任务" << std::endl;
}void execdiv()
{std::cout << "程序[" << getpid() << "] 执行除法操作的任务" << std::endl;
}void load()
{//即0号编号的任务是execadddesc.insert({call.size(),"execadd : 加法"});call.push_back(execadd);desc.insert({call.size(),"execsub : 减法"});call.push_back(execsub);desc.insert({call.size(),"execmul : 乘法"});call.push_back(execmul);desc.insert({call.size(),"execdiv : 除法"});call.push_back(execdiv);
}void show()
{for(const auto& e : desc){std::cout << e.first << "->" << e.second << std::endl;}
}int tasksize()
{return call.size();
}
人为输入的结果如下:
自动选择任务的结果如下:
小型的进程池实现完毕
命名管道
关于匿名管道和命名管道,上面所举的例子都是匿名管道,匿名管道缺点是只能由有亲缘关系的进程进行通信,如果想要两个毫不相关的进程进行通信,就需要用到命名管道了
匿名管道和命名管道一样,都是两个进程通过同一份文件,进行通信,只不过看到同一份文件的手段、途径是不一样的
匿名管道是子进程通过继承父进程的方式,打开同一份文件
命名管道是创建一个管道文件,并且让两个不相关的进程打开同一个文件
创建管道文件需要用到mkfifo,可以在指定路径下创建命名管道
man查看:
下面就是创建一个name_pipe的管道文件:
圈中的p就是指管道文件
下面具体演示现象:
首先复制SSH渠道,形成左右两个窗口(即有两个毫不相关的进程),在同一个路径下,:
左边的窗口,先往name_pipe中重定向一句话hello,写到name_pipe中:
这时由于左边的窗口写了内容,但是右边窗口并没有打开,所以此时处于阻塞状态
所以此时右边窗口cat从管道中把数据读取出来:
此时完成了一个进程向另一个进程通过管道的方式写入消息的过程
如果想删除管道文件,可以rm,也可以unlink
mkfifo
man 3 mkfifo查看mkfifo函数
头文件:
sys/types.h和sys/stat.h
参数:
第一个参数pathname,表示特定的路径
第二个参数mode,open时也见过,表示需要指定权限(例如0666,6表示rw-)
返回值:
mkfifo成功了返回0,小于0表示创建失败
下面用样例更清楚的理解命名管道的知识:
创建两个.cc(.cc、.cxx、.cpp是一样的)文件,分别是client.cc和server.cc,分别表示服务器端和顾客端
common.hpp表示client.cc和server.cc必须包含的文件
log.hpp表示每次执行完操作,打印提示信息
makefile当然也是有的,方便操作
下面看具体演示,首先复制一个ssh渠道,让两个毫不相干的进程都在一个路径下(具体代码在演示结果的下面)
左边窗口当做server端,右边窗口当做client端:
左边先运行server:
这时打印显示创建管道文件成功,再在右边窗口运行client:
这时server端会显示打开管道文件成功
接着在client端输入信息,分别输入你好,再见:
这时serve端也会显示对应输入的信息,最后client端Ctrl + c 退出进程:
server端会显示左边红框的信息,整个过程结束
下面是具体代码:
Makefile:
.PHONY:all
all:client serverclient:client.ccg++ -o $@ $^ -std=c++11
server:server.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f client server
common.hpp:
//如果_COMMON_H_不存在就define
#ifndef _COMMON_H_
#define _COMMON_H_#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "log.hpp"using namespace std;#define SIZE 128
//设置MODE权限为0666
#define MODE 0666
//设置路劲path为当前路径的fifo.ipc文件
string path = "./fifo.ipc";#endif
log.hpp:
#ifndef _LOG_H_
#define _LOG_H_#include <iostream>
#include <ctime>std::ostream &log(std::string message)
{std::cout << " | " << (unsigned)time(nullptr) << " | " << message;return std::cout;
}#endif
client.cc:
#include "common.hpp"int main()
{//获取管道文件int fd = open(path.c_str(), O_WRONLY);if(fd < 0){perror("open");exit(1);}//通信过程string buffer; while(true){cout << "请输入信息:";getline(cin, buffer);write(fd,buffer.c_str(),buffer.size());}//关闭fdclose(fd);return 0;
}
server.cc
#include "common.hpp"int main()
{//创建管道文件if(mkfifo(path.c_str(), MODE) < 0){perror("mkfifo");exit(1);}log("创建管道文件成功!") << endl;//文件操作int fd = open(path.c_str(), O_RDONLY);if(fd < 0){perror("open");exit(2);}log("打开管道文件成功!") << endl;//编写通信代码char buffer[SIZE];while(true){//清空buffermemset(buffer,'\0',sizeof(buffer));//从管道文件中读取//sizeof(buffer)-1是因为系统接口不考虑\0结尾ssize_t s = read(fd,buffer,sizeof(buffer) - 1);if(s > 0){//读取成功cout << "client :" << buffer << endl;}else if(s == 0){//读到了文件结尾cerr << "读到文件结尾,client和server都退出" << endl;break;}else{//读取失败perror("read");break;}}//关闭文件close(fd);log("关闭管道文件成功!") << endl;//通信完毕就删除文件unlink(path.c_str());log("删除管道文件成功!") << endl;return 0;
}
三、system v共享内存
操作系统提供了共享内存,共享内存的本质:
共享内存 = 共享内存块 + 共享内存对应的内核数据结构
共享内存映射到各自进程的地址空间后,不用经过系统调用,直接可以访问,意味着双方进程要通信,直接进行内存级的读和写即可
shmget函数(创建)
首先共享内存需要用到shmget函数,作用是创建并获取共享内存
man 2 shmget查看:
包含了两个头文件:
sys/ipc.h和sys/shm.h
函数参数:
第一个参数key(key_t其实就是32位的整数类型),是用于保证通信的双方进程看到的是同一份共享内存,key在系统是唯一的,通信双方使用同一个key,只要key值相同,通信双方就是看到了同一个共享内存
第二个参数size,表示要创建的共享内存有多大
第三个参数shmflg,一般由两种选项(IPC_CREAT、IPC_EXCL)
IPC_CREAT:单独使用时,如果底层已经存在,就获取并返回它;如果底层不存在,就创建并返回它
IPC_EXCL:单独使用是无意义的
IPC_CREAT、IPC_EXCL共同使用:如果底层不存在,创建并返回;如果底层已经存在,出错返回
创建共享内存要有权限,还需 | 0666
函数返回值:
成功则返回一个合法的标识符,失败就返回-1
下面具体讲解关于第一个参数key的相关知识
ftok函数(生成key)
为了能够形成唯一的key值,需要用到函数ftok
ftok其实是一套算法,只是为了将pathname和proj_id联合起来,形成一个唯一值
ftok包含头文件sys/types.h和sys/ipc.h
参数pathname是路径,proj_id是项目id(一般是0~255之间)
ftok的返回值成功就返回key,失败返回-1
下面是ftok的使用:
shmserver.cc:
shmclient.cc:
common.hpp:
此时运行代码,观察shmserver.cc和shmclient.cc中创建出来的key值:
通过结果发现,key值相同
共享内存的声明周期是随内核的
查看共享内存:ipcs -m
上图的perm就是权限,nattch与该共享内存关联的进程个数,status是状态
手动删除共享内存:ipcrm -m [shmid]
删除后shmid为0的共享内存就被删除了
这是手动删除共享内存,比较麻烦,也有自动删除共享内存的函数
shmctl函数(删除)
man查看shmctl:
包含头文件sys/ipc.h和sys/shm.h
函数参数:
第一个参数shmid,就是创建的共享内存用户管理的id
第二个参数cmd,有不同的选项,对这个共享内存有不同的操作方案(删除一般用IPC_RMID)
IPC_RMID即使有进程与当前共享内存的shm挂接,依旧删除共享内存
第三个参数通常用于获取共享内存的属性,一般只进行删除操作时,设为空即可
函数返回值:
失败会返回-1,成功会返回0
使用如下(shmid是自己创建的):
shmat/dt函数(挂接/去关联)
at就是attch的意思
man shmat:
包含头文件sys/types.h和sys/shm.h
shmat函数参数
第一个参数shmid,就是要挂接的共享内存的用户管理的id
第二个参数shmaddr,就是要挂接这个共享内存的虚拟地址(由于虚拟地址我们并不清楚,所以一般设为空,让OS帮我们自由挂接)
第三个参数shmflg,就是挂接共享内存的挂接方式(例如SHM_RDONLY只读,如果是0表示默认)
shmat函数返回值:
成功时会返回挂接成功的共享内存的地址(虚拟地址),失败返回-1
shmdt函数参数:
shmaddr就是刚刚shmat函数成功后的返回值
shmdt函数返回值:
成功返回0,失败返回-1
共享内存总结的结论:
只要通信双方使用共享内存,一方直接向共享内存中写入,另一方马上就能看到对方写入的数据,所以共享内存是所以进程间通信中速度最快的,因为不需要过多的拷贝
与管道不同,共享内存缺乏访问控制,所以会带来并发问题
由于共享内存缺乏访问控制,而管道是有访问控制的,所以我们可以借助管道使得共享内存拥有访问控制
下面例子具体演示(代码下演示结果的下方)
有以下文件:
首先复制SSH渠道,使得两个窗口都处于同一路径下,一端作为server端,一端作为client端:
接着make,创建两个可执行文件:
左边窗口当做server端,右边当做client端:
左边窗口server端显示没有数据,等待中,等待右边窗口client端输入数据
右边输入数据后结果如下:
最后想退出时,client端输入quit即可:
下面是上述例子的代码部分:
Makefile:
.PHONY:all
all:shmclient shmservershmclient:shmclient.ccg++ -o $@ $^ -std=c++11
shmserver:shmserver.ccg++ -o $@ $^ -std=c++11.PHONY:clean
clean:rm -f shmclient shmserver
log.hpp:
#ifndef _LOG_H_
#define _LOG_H_#include <iostream>
#include <ctime>std::ostream &log(std::string message)
{std::cout << " | " << (unsigned)time(nullptr) << " | " << message;return std::cout;
}#endif
common.hpp:
#pragma once#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <cassert>
#include <fcntl.h>#include "log.hpp"using namespace std;#define PATH_NAME "/home/fcy"
#define PROJ_ID 0x55
//共享内存的大小最好是页的整数倍(4096)
#define SHM_SIZE 4096
#define FIFO_NAME "./fifo"//由于共享内存没有访问控制
//所以借助管道,实现访问控制
class Init
{
public:Init(){umask(0);int n = mkfifo(FIFO_NAME,0666);assert(n == 0);(void)n;log("创建管道文件成功!");}~Init(){unlink(FIFO_NAME);log("删除管道文件成功!");}
};#define READ O_RDONLY
#define WRITE O_WRONLY//使用下面的函数进行读写操作
int OpenFIFO(std::string pathname, int flags)
{int fd = open(pathname.c_str(),flags);assert(fd >= 0);return fd;
}
//读取需要等待别人写入
void Wait(int fd)
{log("没有数据,等待中") << endl;uint32_t str = 0;ssize_t s = read(fd,&str,sizeof(uint32_t));assert(s == sizeof(uint32_t));(void)s;
}
//写入信息
int Signal(int fd)
{uint32_t str = 1;ssize_t s = write(fd,&str,sizeof(uint32_t));assert(s == sizeof(uint32_t));(void)s;log("正在等待输入数据") << endl;}
//关闭该文件描述符
void CloseFIFO(int fd)
{close(fd);
}
shmserver.cc:
#include "common.hpp"//程序加载时自动构建全局变量,自动调用该类的构造函数
//程序退出时,全局变量被析构,自动调用析构函数删除管道文件
Init init;//将10进制数转化为16进制,方便查看共享内存时观察
string Trans(key_t k)
{char buffer[32];snprintf(buffer, sizeof(buffer), "0x%x", k);return buffer;
}int main()
{//创建公共的keykey_t k = ftok(PATH_NAME, PROJ_ID);assert(k != -1);log("创建key成功") << "server key->" << Trans(k) << endl;//创建共享内存,建议创建全新的共享内存int shmid = shmget(k,SHM_SIZE,IPC_CREAT|IPC_EXCL|0666);if(shmid == -1){perror("shmget");exit(1);}log("创建共享内存成功") << "shmid->" << shmid << endl;//将指定的共享内存,挂接到自己的地址空间char* shmaddr = (char*)shmat(shmid,nullptr,0);log("挂接共享内存成功") << "shmid->" << shmid << endl;int fd = OpenFIFO(FIFO_NAME,READ);//将共享内存当做字符串使用for(;;){//刚开始先wait,数据为空,先不读取Wait(fd);printf("%s\n",shmaddr);//当client端不写入了,server端就停止读取共享内存的数据if(strcmp(shmaddr, "quit") == 0)break;}//将指定的共享内存,从自己的地址空间中去除关联int ret = shmdt(shmaddr);assert(ret != -1);(void)ret;log("去关联共享内存成功") << "shmid->" << shmid << endl;//删除共享内存ret = shmctl(shmid,IPC_RMID,nullptr);assert(ret != -1);(void)ret;log("删除共享内存成功") << "shmid->" << shmid << endl;//关闭文件描述符CloseFIFO(fd);return 0;
}
shmclient.cc:
#include "common.hpp"int main()
{//创建公共的keykey_t k = ftok(PATH_NAME, PROJ_ID);if(k < 0){perror("ftok");exit(1);}log("创建key成功") << "client key->" << k << endl;//获取共享内存int shmid = shmget(k,SHM_SIZE,0);if(shmid < 0){perror("ftok");exit(2);}log("创建共享内存成功") << "shmid->" << shmid << endl;//挂接共享内存char* shmaddr = (char*)shmat(shmid,nullptr,0);if(shmaddr == nullptr){perror("ftok");exit(3);}log("挂接共享内存成功") << "shmid->" << shmid << endl;int fd = OpenFIFO(FIFO_NAME,WRITE);//将共享内存当做char类型的bufferwhile(true){ssize_t s = read(0,shmaddr,SHM_SIZE-1);if(s > 0){//将quit后面的回车(\n)变为\0//否则无法判断是quit这个字符串shmaddr[s-1] = 0;Signal(fd);if(strcmp(shmaddr,"quit") == 0)break;}}//关闭CloseFIFO(fd);//将指定的共享内存,从自己的地址空间中去除关联int ret = shmdt(shmaddr);assert(ret != -1);(void)ret;log("去关联共享内存成功") << "shmid->" << shmid << endl;//client不需要删除共享内存return 0;
}
四、初识信号量
我们之前所学的,为了进程间通信,都是要让不同的进程看到同一份资源,而不同的进程的进程看到同一份资源,会带来时序问题,造成数据不一致的问题
多个执行流互相运行是相互干扰,主要是访问临界资源时不加以保护,而在非临界区是不会互相干扰的
下面引入几个概念:
临界资源:多个进程(执行流)看到的公共的一份资源称为临界资源
临界区:自己的进程访问临界资源的代码称为临界区
互斥:为了更好的进行临界区的保护,可以让多个执行流在任意时刻只能有一个进程进入临界区,这种方式称为互斥
原子性:要么不做,要么做完,没有中间状态,称之为原子性
每一个进程想要进入临界资源,访问临界资源的一部分,需要先申请信号量,才能使用临界资源
信号量就像一个计数器一样,申请信号量的本质是让信号量计数器--
申请信号量成功后,临界资源内部就会预留你想要的资源,是对临界资源的预定机制
所以申请信号量,信号量计数器--,称之为信号量的P操作,必须是原子的
而释放信号量,信号量计数器会++,称之为信号量的V操作,也必须是原子的
访问临界资源也就是进程执行自己的临界区代码
剩下详细的信号量知识在多线程部分会说到