Linux:进程间通信

Linux进程间通信
进程间通信介绍与环境配置
介绍
进程间通信(Inter-Process Communication,即IPC)的目的:数据传输、资源共享、通知事件、进程控制。
数据传输:一个进程需要将它的数据发送给另一个进程。
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
分类:
管道:匿名管道pipe、命名管道。
System V IPC:System V 消息队列、System V 共享内存、System V 信号量。
POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量、读写锁。
环境配置
Ubuntu 20.04以上,在购买云服务器的官网重装系统。官网下载VS Code,下载插件Remote - SSH,连接自己的云服务器。
Ctrl + ~ 在VS code打开终端。
其他插件。
如果无法用拼音输入中文注释,尝试不要使用微软拼音,使用其他输入法。
匿名管道
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的数据流称为管道。
进程具有独立性,进程间通信的条件是先让不同进程看到同一份资源,即建立管道。
man pipe 查看手册。
int pipe(int pipefd[2]) 是系统调用,用于创建匿名管道,创建成功返回0,否则返回-1。pipefd[0]是读端,pipefd[1]是写端。数据只能从管道的写端到读端。
父进程调用 pipe () 创建管道,父进程的文件描述符表新增两项,fd=3绑定读端,fd=4绑定写端。父进程 fork () 生成子进程,子进程的fd=3、fd=4同样绑定读端和写端。管道只保留一条数据流,否则会读写阻塞、逻辑混乱,所以父进程关闭读端 fd [0],只留写端 fd [1],负责往管道写数据,子进程关闭写端 fd [1],只留读端 fd [0],负责从管道读数据。
管道在内核层面和普通文件共用同一套文件系统模型,操作接口完全一致:open/read/write/close。父子进程各自独立拥有 file 结构体,管道创建时内核生成特殊管道 inode,父子进程共享同一个 inode,这是父子进程能通信的核心。所以数据流转:父进程执行 write(fd[1], buf, len),数据从父进程用户空间 → 内核管道缓冲区;子进程执行 read(fd[0], buf, len),数据从内核管道缓冲区 → 子进程用户空间。
创建Makefile如下。
testPipe:testPipe.cc
g++ -o $@ $^
.PHONY:clean
clean:
rm -f testPipe
创建testPipe.cc,输入如下代码,创建管道。
#include <iostream>
#include <unistd.h>
int main()
{
    int fds[2]={0};
    int n=pipe(fds);
    if(n<0)
        std::cout<<"pipe error"<<std::endl;
    std::cout<<"fds[0] = "<<fds[0]<<std::endl;
    std::cout<<"fds[1] = "<<fds[1]<<std::endl;
    return 0;
}
可以预见,标准输入、标准输出、标准错误已经占了0、1、2,所以fds[0]=3,fds[0]=4。
创建管道使父子进程间通信。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
void ChildWrite(int wfd)
{
    char buffer[128]={0};
    int cnt=0;
    while(1)
    {
        //格式化写入buffer
        snprintf(buffer,sizeof(buffer),"I am a child. pid: %d cnt = %d",getpid(),cnt++);
        //把buffer写入管道
        write(wfd,buffer,strlen(buffer));
        sleep(1);
    }
}
void FatherRead(int rfd)
{
    char buffer[128]={0};
    while(1)
    {
        buffer[0]='\0';
        //从管道当中读数据到buffer
        ssize_t n=read(rfd,buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]='\0';
            std::cout<<"child say: "<<buffer<<std::endl;
        }
    }
}
int main()
{
    //创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
        std::cout << "pipe error" << std::endl;
    std::cout << "fds[0] = " << fds[0] << std::endl;
    std::cout << "fds[1] = " << fds[1] << std::endl;
    //子进程往管道写数据,父进程从管道读数据
    pid_t id = fork();
    if(id == 0)
    {
        close(fds[0]);//关闭子进程读端
        ChildWrite(fds[1]);
        close(fds[1]);
        exit(0);
    }
    close(fds[1]);//关闭父进程写端
    FatherRead(fds[0]);
    waitpid(id,nullptr,0);
    close(fds[0]);
    return 0;
}
while :; do ps axj | grep testPipe; sleep 1; echo "======================"; done
匿名管道的特点:
匿名管道的进程间通信只能用于父子进程或兄弟进程。
管道也是文件,生命周期随进程。
管道是字节流,数据只是一串连续字节,内核不会保存 “写入分段边界”,操作系统只单纯搬运字节,不存在独立 “数据包 / 消息单元”。
管道自带同步与互斥机制。同一时间只允许一个进程访问管道,防止多个进程同时写造成数据错乱。协调读写进程的执行顺序,缓冲区空时,读进程阻塞等待写进程写入数据(无数据可读时,read 休眠阻塞);缓冲区满时,写进程阻塞等待读进程取走数据。
管道是单向通信的,即半双工。任意时刻,一个发,一个收,则为半双工;任意时刻同时发收为全双工。管道半双工,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
管道4种通信情况。
读得慢,写得快。父进程每隔5秒读一次,子进程一直写。子进程快速连续 write 多条字符串,字节全部堆积在内核管道缓冲区,内核不会区分每一次 write 的边界,只是单纯存放连续字节。子进程不停写入,父进程 5 秒才读取一次;在这 5 秒内,大量字节塞满管道缓冲区。管道缓冲区有上限,缓冲区写满后,write 会阻塞。5 秒后父进程执行 read,一次性把缓冲区里堆积的所有字节全部读出。
读得快,写得慢。父进程一直读,子进程5秒写一次。子进程 5 秒才 write 一次,每次只写入一条短字符串;父进程读完一条后会立刻阻塞在read系统调用上等待新数据。父进程执行read时,管道缓冲区是空的,内核会将父进程休眠,放入管道读等待队列;直到 5 秒后子进程写入一条数据,内核唤醒阻塞的父进程。
读继续,写关闭。父进程一直读,子进程写一次就退出。管道写端关闭,父进程read相当于读到文件末尾,返回0。
读关闭,写继续。此时写入无意义,系统会杀掉写端进程,异常信号 SIGPIPE。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
void FatherRead(int rfd)
{
    char buffer[128]={0};
    while(1)
    {
        sleep(1);
        buffer[0]='\0';
        ssize_t n=read(rfd,buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]='\0';
            std::cout<<"child say: "<<buffer<<std::endl;
        }
        else
            std::cout<<"n = "<<n<<std::endl;
        break;
    }
}
void ChildWrite(int wfd)
{
    char buffer[128]={0};
    int cnt=0;
    while(1)
    {
        snprintf(buffer,sizeof(buffer),"I am a child. pid: %d cnt = %d",getpid(),cnt++);
        write(wfd,buffer,strlen(buffer));
        sleep(1);
        //break;
    }
}
int main()
{
    //创建管道
    int fds[2] = {0};
    int n = pipe(fds);
    if (n < 0)
        std::cout << "pipe error" << std::endl;
    std::cout << "fds[0] = " << fds[0] << std::endl;
    std::cout << "fds[1] = " << fds[1] << std::endl;
    //子进程往管道写数据,父进程从管道读数据
    pid_t id = fork();
    if(id == 0)
    {
        close(fds[0]);//关闭子进程读端
        ChildWrite(fds[1]);
        close(fds[1]);
        exit(0);
    }
    close(fds[1]);//关闭父进程写端
    FatherRead(fds[0]);
    close(fds[0]);
    int status = 0;
    int ret=waitpid(id,&status,0);
    if(ret>0)
        printf("exit code: %d  exit signal: %d\n",(status>>8)&0xFF,status&0x7F);
    return 0;
}
测试管道容量。子进程一直往管道写入一个字节,父进程休眠100秒。父进程休眠期间,子进程会写满管道缓冲区,然后write阻塞。
void FatherRead(int rfd)
{
    char buffer[128]={0};
    while(1)
    {
        sleep(100);
        buffer[0]='\0';
        ssize_t n=read(rfd,buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]='\0';
            std::cout<<"child say: "<<buffer<<std::endl;
        }
        else
            std::cout<<"n = "<<n<<std::endl;
        //break;
    }
}
void ChildWrite(int wfd)
{
    //char buffer[128]={0};
    char c=0;
    int cnt=0;
    while(1)
    {
        //snprintf(buffer,sizeof(buffer),"I am a child. pid: %d cnt = %d",getpid(),cnt++);
        write(wfd,&c,1);
        printf("child: %d\n",cnt++);
        //sleep(1);
        //break;
    }
}
最终得到cnt为65535,即写入了65536个字节,即ubuntu下管道容量为64KB。
管道读写规则:
管道为空,无数据可读。
管道为默认阻塞模式,read()阻塞休眠,进程卡在 read 系统调用,等待其他进程写入数据,有数据才会唤醒、返回读到的字节数。
管道为非阻塞模式,read()不会阻塞,立刻返回 -1,全局变量错误码errno设置为EAGAIN。
管道为满。
管道为阻塞模式,write()阻塞休眠,进程卡在 write,等待读进程读取管道数据、腾出缓冲区空间,有空位才会唤醒继续写入。
管道为非阻塞模式,write()不阻塞,直接返回 -1,errno=EAGAIN。
如果所有管道写端对应的文件描述符被关闭,管道没有任何进程能写入新数据,此时调用read:
缓冲区还有残留数据 → 读完数据返回字节数;
缓冲区空 → read直接返回0,代表读到 EOF,数据流永久结束。
如果所有管道读端对应的文件描述符被关闭,没有任何进程读取管道数据,此时进程调用write,内核立刻向执行 write 的进程发送 SIGPIPE (13) 管道破裂信号;进程默认会被该信号直接杀死、异常退出。
当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。Linux下,PIPE_BUF = 4096 。原子性:本次 write 的全部字节,会一次性完整写入缓冲区。
当要写入的数据量大于PIPE_BUF时,Linux不再保证写入的原子性,数据会被内核拆分成分段写入。
创建进程池处理任务。
父进程创建多个子进程,同时创建多个管道,父进程通过不同管道与对应子进程完成进程间通信。父进程往管道写入任务码,子进程从管道读取任务码,然后子进程去处理对应任务。在文件Main.cc中完成main函数。创建文件ProcessPool.hpp实现对管道的描述、管理管道、进程池。创建文件Task.hpp实现管理任务。
Makefile如下。
process_pool:Main.cc
g++ $^ -o $@ -std=c++11
.PHONY:clean
clean:
rm -f process_pool
.h文件只能放置声明,.hpp文件能同时放声明和实现,C++编译器直接认定这是纯 C++ 头文件,只用于 C++ 项目,不兼容 C 语言。
类Channel描述管道。需要成员管道名、管道写端、管道读端的子进程的pid。管道得到任务码后需要发给子进程。
//管道
class Channel
{
public:
    Channel(int fd, pid_t id)
        : _wfd(fd), _subid(id)
    {
        _name = "channel - " + std::to_string(_wfd) + " - " + std::to_string(_subid);
    }
    ~Channel()
    {}
    //发送任务码
    void Send(int code)
    {
        write(_wfd,&code,sizeof(code));
    }
    //关闭管道写端
    void Close()
    {
        close(_wfd);
    }
    //回收子进程
    void Wait()
    {
        waitpid(_subid,nullptr,0);
    }
    int GetWfd()
    {
        return _wfd;
    }
    pid_t GetSubid()
    {
        return _subid;
    }
    std::string GetName()
    {
        return _name;
    }
private:
    int _wfd;
    pid_t _subid;
    std::string _name;
};
类ChannelManager管理管道。把管道放入数组 _ channels 中管理,_next为使用管道的下标。不能只使用一个管道,要负载均衡,可以用负载指标、随机选取、轮询等方式选择管道。
//组织管道
class ChannelManager
{
public:
    ChannelManager()
        :_next(0)
    {}
    ~ChannelManager()
    {}
    void Insert(int wfd,pid_t subid)
    {
        //_channels.push_back({wfd,subid});
        _channels.emplace_back(wfd,subid);
    }
    //轮询选择管道
    Channel& Select()
    {
        Channel& c = _channels[_next];
        _next++;
        _next %= _channels.size();
        return c;
    }
    void StopAndWait()
    {
        for(auto& c:_channels)
        {
            c.Close();
            std::cout<<"关闭"<<c.GetName()<<std::endl;
            c.Wait();
            std::cout<<"回收"<<c.GetName()<<std::endl;
        }
    }
    void CloseAll()
    {
        for(auto& c:_channels)
        {
            c.Close();
        }
    }
    void PrintChannel()
    {
        for(auto& c:_channels)
            std::cout<<c.GetName()<<std::endl;
    }
private:
    std::vector<Channel> _channels;
    int _next;
};
类TaskManager管理任务。函数PrintLog()、DownLoad()、UpLoad()模拟任务,函数指针放入任务集合。用随机数获得任务集合下标作为任务码。
typedef void(*task_t)();
void PrintLog()
{
    std::cout<<"这是一个打印日志的任务...\n"<<std::endl;
}
void DownLoad()
{
    std::cout<<"这是一个下载的任务...\n"<<std::endl;
}
void UpLoad()
{
    std::cout<<"这是一个上传的任务...\n"<<std::endl;
}
class TaskManager
{
public:
    TaskManager()
    {
        srand(time(0));
    }
    ~TaskManager()
    {}
    //添加任务
    void Register(task_t t)
    {
        _tasks.push_back(t);
    }
    //获取任务码
    int TaskCode()
    {
        return rand() % _tasks.size();
    }
    //执行任务
    void Execute(int code)
    {
        if(code>=0 && code<_tasks.size())
            _tasks[code]();
    }
private:
    std::vector<task_t> _tasks; //任务集合
};
类ProcessPool实现进程池。需要ChannelManager管理管道,TaskManager管理任务,需要确定创建子进程的个数。
Start()函数创建管道子进程。Work(int rfd)让子进程从管道读取任务码,然后子进程调用TaskManager的Execute()执行任务。父进程调用Run(),在其中调用TaskCode()获得任务码,调用Select()选择管道,父进程最后通过管道发送任务码。
//进程池
const int g_process_num = 5;
class ProcessPool
{
public:
    ProcessPool(int num)
        :_process_num(num)
    {
        _tm.Register(PrintLog);
        _tm.Register(DownLoad);
        _tm.Register(UpLoad);
    }
    ~ProcessPool()
    {}
    void Work(int rfd)
    {
        while(1)
        {
            int code=0;
            ssize_t n = read(rfd,&code,sizeof(code));
            if(n > 0)
            {
                if(n!=sizeof(code))
                    continue;
                std::cout<<"子进程["<<getpid()<<"]收到一个任务码:"<<code<<std::endl;
                _tm.Execute(code);
            }
            else if(n == 0)
            {
                std::cout<<"子进程退出"<<std::endl;
                break;
            }
            else
            {
                std::cout<<"读取错误"<<std::endl;
                break;
            }
        }
    }
    bool Start()
    {
        for(int i=0;i<_process_num;i++)
        {
            int pipefd[2]={0};
            int n=pipe(pipefd);
            if(n < 0)
            {
                std::cout<<"pipe error!"<<std::endl;
                return false;
            }
            pid_t subid=fork();
            if(subid < 0)
            {
                std::cout<<"fork error!"<<std::endl;
                return false;
            }
            else if(subid == 0)
            {
                //子进程
                close(pipefd[1]);
                _cm.CloseAll();   //关闭因写时拷贝而打开的其他管道的写端
                Work(pipefd[0]);
                close(pipefd[0]);
                exit(0);
            }
            else
            {
                //父进程
                close(pipefd[0]);
                _cm.Insert(pipefd[1],subid);
            }
        }
        return true;
    }
    void Run()
    {
        int code = _tm.TaskCode(); //选择一个任务
        std::cout<<"发送任务码:"<<code<<std::endl;
        Channel c=_cm.Select(); //选择一个管道
        std::cout<<"选择子进程:"<<c.GetName()<<std::endl;
        c.Send(code); //发送任务
    }
    void Stop()
    {
        _cm.StopAndWait();
    }
private:
    int _process_num;
    ChannelManager _cm;
    TaskManager _tm;
};
开始时,父进程创建管道1,文件描述符3为管道1读端,4为管道1写端。父进程创建子进程1,此时子进程1的文件描述符与父进程一致,父进程关闭读端,文件描述符3空闲,子进程1的文件描述符不变。
然后,父进程创建管道2,文件描述符3为管道2读端,5为管道2写端。父进程创建子进程2,子进程2继承父进程,子进程2的文件描述符4也为管道1的写端。以此类推,子进程n 会连接 管道1~管道n-1 的写端,所以子进程n关闭对应管道n的写端后,要调用CloseAll()关闭对其余管道的写端,由于写时拷贝,子进程调用CloseAll()时不会影响其他子进程。
Main.cc如下。
#include "ProcessPool.hpp"
int main()
{
    //创建进程池
    ProcessPool pp(g_process_num);
    //启动进程池
    pp.Start();
    //自动派发任务
    int cnt=10;
    while(cnt--)
    {
        pp.Run();
        sleep(1);
    }
    //结束进程池
    pp.Stop();
    return 0;
}
调用Start()启动进程池,创建5个管道和对应的5个子进程,此时管道为空,子进程都阻塞在Work()中的read()。自动派发10次任务,父进程执行到pp.Run(),父进程轮询选择一个管道并将随机获得的任务码通过管道发送给子进程,子进程执行任务。最后Stop()结束进程池。
总的代码如下。
//Task.hpp
#pragma once
#include <iostream>
#include <vector>
#include <ctime>
typedef void(*task_t)();
void PrintLog()
{
    std::cout<<"这是一个打印日志的任务...\n"<<std::endl;
}
void DownLoad()
{
    std::cout<<"这是一个下载的任务...\n"<<std::endl;
}
void UpLoad()
{
    std::cout<<"这是一个上传的任务...\n"<<std::endl;
}
class TaskManager
{
public:
    TaskManager()
    {
        srand(time(0));
    }
    ~TaskManager()
    {}
    //添加任务
    void Register(task_t t)
    {
        _tasks.push_back(t);
    }
    //获取任务码
    int TaskCode()
    {
        return rand() % _tasks.size();
    }
    //执行任务
    void Execute(int code)
    {
        if(code>=0 && code<_tasks.size())
            _tasks[code]();
    }
private:
    std::vector<task_t> _tasks; //任务集合
};
//ProcessPool.hpp
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__
#include <iostream>
#include <vector>
#include <string>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
#include "Task.hpp"
//父进程往管道写,子进程从管道读
//管道
class Channel
{
public:
    Channel(int fd, pid_t id)
        : _wfd(fd), _subid(id)
    {
        _name = "channel - " + std::to_string(_wfd) + " - " + std::to_string(_subid);
    }
    ~Channel()
    {}
    //发送任务码
    void Send(int code)
    {
        write(_wfd,&code,sizeof(code));
    }
    //关闭管道写端
    void Close()
    {
        close(_wfd);
    }
    //回收子进程
    void Wait()
    {
        waitpid(_subid,nullptr,0);
    }
    int GetWfd()
    {
        return _wfd;
    }
    pid_t GetSubid()
    {
        return _subid;
    }
    std::string GetName()
    {
        return _name;
    }
private:
    int _wfd;
    pid_t _subid;
    std::string _name;
};
//组织管道
class ChannelManager
{
public:
    ChannelManager()
        :_next(0)
    {}
    ~ChannelManager()
    {}
    void Insert(int wfd,pid_t subid)
    {
        //_channels.push_back({wfd,subid});
        _channels.emplace_back(wfd,subid);
    }
    //轮询选择管道
    Channel& Select()
    {
        Channel& c = _channels[_next];
        _next++;
        _next %= _channels.size();
        return c;
    }
    void StopAndWait()
    {
        for(auto& c:_channels)
        {
            c.Close();
            std::cout<<"关闭"<<c.GetName()<<std::endl;
            c.Wait();
            std::cout<<"回收"<<c.GetName()<<std::endl;
        }
    }
    void CloseAll()
    {
        for(auto& c:_channels)
        {
            c.Close();
        }
    }
    void PrintChannel()
    {
        for(auto& c:_channels)
            std::cout<<c.GetName()<<std::endl;
    }
private:
    std::vector<Channel> _channels;
    int _next;
};
//进程池
const int g_process_num = 5;
class ProcessPool
{
public:
    ProcessPool(int num)
        :_process_num(num)
    {
        _tm.Register(PrintLog);
        _tm.Register(DownLoad);
        _tm.Register(UpLoad);
    }
    ~ProcessPool()
    {}
    void Work(int rfd)
    {
        while(1)
        {
            int code=0;
            ssize_t n = read(rfd,&code,sizeof(code));
            if(n > 0)
            {
                if(n!=sizeof(code))
                    continue;
                std::cout<<"子进程["<<getpid()<<"]收到一个任务码:"<<code<<std::endl;
                _tm.Execute(code);
            }
            else if(n == 0)
            {
                std::cout<<"子进程退出"<<std::endl;
                break;
            }
            else
            {
                std::cout<<"读取错误"<<std::endl;
                break;
            }
        }
    }
    bool Start()
    {
        for(int i=0;i<_process_num;i++)
        {
            int pipefd[2]={0};
            int n=pipe(pipefd);
            if(n < 0)
            {
                std::cout<<"pipe error!"<<std::endl;
                return false;
            }
            pid_t subid=fork();
            if(subid < 0)
            {
                std::cout<<"fork error!"<<std::endl;
                return false;
            }
            else if(subid == 0)
            {
                //子进程
                close(pipefd[1]);
                _cm.CloseAll();   //关闭因写时拷贝而打开的其他管道的写端
                Work(pipefd[0]);
                close(pipefd[0]);
                exit(0);
            }
            else
            {
                //父进程
                close(pipefd[0]);
                _cm.Insert(pipefd[1],subid);
            }
        }
        return true;
    }
    void Run()
    {
        int code = _tm.TaskCode(); //选择一个任务
        std::cout<<"发送任务码:"<<code<<std::endl;
        Channel c=_cm.Select(); //选择一个管道
        std::cout<<"选择子进程:"<<c.GetName()<<std::endl;
        c.Send(code); //发送任务
    }
    void Stop()
    {
        // _cm.StopSubProcess(); //关闭父进程在所有管道的写端
        // _cm.WaitSubProcess(); //回收所有子进程
        _cm.StopAndWait();
    }
private:
    int _process_num;
    ChannelManager _cm;
    TaskManager _tm;
};
#endif
//Main.cc
#include "ProcessPool.hpp"
int main()
{
    //创建进程池
    ProcessPool pp(g_process_num);
    //启动进程池
    pp.Start();
    //自动派发任务
    int cnt=10;
    while(cnt--)
    {
        pp.Run();
        sleep(1);
    }
    //结束进程池
    pp.Stop();
    return 0;
}
其中下方的宏 与 #pragma once 都能使头文件不被重复包含。下方的宏的含义为如果 __PROCESS_POOL_HPP__ 未定义,则编译下面代码。如果头文件被包含过一次,则执行 #define __PROCESS_POOL_HPP__ ,定义了 __PROCESS_POOL_HPP__ ,接下来再包含头文件就因为条件编译而不编译 #ifndef 和 #endif 之间的代码。
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__
//...
#endif
命名管道
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件。
进程 A、进程 B 分别打开同一个磁盘文件 /a/b/c.txt 。内核不会在内存加载两份 inode、两份文件缓冲,文件加载进内存后,只有一份 inode、一份内核缓冲区,A、B 进程各自创建独立的 struct file 文件结构体(记录各自读写偏移、打开模式),但两个 struct file 指向同一个内存 inode和缓冲区。
只要打开同一路径的同一个文件(唯一性),无关进程就能访问内核里同一份共享资源。
命名管道的原理也是如此。命名管道在磁盘上真实存在、有独立路径。命名管道磁盘只存 inode 元数据(标记这是管道类型文件),读写的缓冲区完全放在内核内存,数据不会落地磁盘,和匿名管道缓冲区逻辑一致。
无关进程的通信流程:
任意进程提前创建命名管道文件,生成唯一路径;
进程 1(写端)打开该管道路径,写入数据;
进程 2(读端)打开同一个管道路径,内核会让两个进程指向同一份管道内核缓冲区,完成无关进程数据传输。
匿名管道与命名管道的对比。
特性 匿名管道 pipe 命名管道 FIFO
磁盘文件 无,仅内核临时资源 有,磁盘存在带路径的管道文件
通信进程 仅限有血缘父子进程 任意无关进程,只要能访问管道路径
数据存储 内核内存缓冲区,不落地磁盘 内核内存缓冲区,磁盘仅存文件标记
生命周期 所有进程关闭管道后自动销毁 磁盘文件永久存在,可手动删除
阻塞模式下,只读打开 FIFO,则阻塞等待写进程打开;只写打开 FIFO,则阻塞等待读进程打开。
mkfifo 文件名 创建命名管道。
echo命令往命名管道fifo中写入内容,cat命令从fifo中读取内容,echo命令和cat命令运行起来是进程,这就实现了进程间通信。
unlink 文件名 删除命名管道。
mkfifo 也有C语言封装的库函数。
创建的管道所在路径为pathname,管道文件的权限为mode。成功返回0,失败返回-1。
unlink 系统调用。成功返回0,失败返回-1。
用命名管道实现server和client通信。
创建文件server.cc和client.cc,各自生成可执行文件server和client。在client写内容通过命名管道发送到server,server再写到显示器。
Makefile如下。
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f server client
all 的作用是命令行输入 make 时,能一起编译出server和client。如果没有 all ,命令行输入 make 就只能编译第一个server。
创建文件common.hpp,实现类NamedFifo,在这个类中专门创建和销毁命名管道文件。实现类FileOper,在这个类中实现通用的文件读写等操作。
//common.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#define PATH "."
#define FILENAME "fifo"
#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)
//创建管道文件
class NamedFifo
{
public:
    NamedFifo(const std::string& path, const std::string& name)
        :_path(path),_name(name)
    {
        _filename = path + "/" + name;
        //新建命名管道
        umask(0);
        int n = mkfifo(_filename.c_str(),0666);
        if(n < 0)
            ERR_EXIT("mkfifo");
        else
            std::cout<<"mkfifo success"<<std::endl;
    }
    ~NamedFifo()
    {
        int n = unlink(_filename.c_str());
        if(n < 0)
            ERR_EXIT("unlink");
        else
            std::cout<<"remove "<<_filename<<" success"<<std::endl;
    }
private:
    std::string _path;
    std::string _name;
    std::string _filename;
};
//文件操作
class FileOper
{
public:
    FileOper(const std::string& path, const std::string& name)
        :_path(path),_name(name),_fd(-1)
    {
        _filename = path + "/" + name;
    }
    ~FileOper()
    {}
    void OpenForRead()
    {
        _fd = open(_filename.c_str(),O_RDONLY);
        if(_fd < 0)
            ERR_EXIT("OpenForRead");
        else
            std::cout<<"open success"<<std::endl;
    }
    void OpenForWrite()
    {
        _fd = open(_filename.c_str(),O_WRONLY);
        if(_fd < 0)
            ERR_EXIT("OpenForWrite");
        else
            std::cout<<"open success"<<std::endl;
    }
    void Read()
    {
        while (1)
        {
            char buffer[1024];
            int n = read(_fd,buffer,sizeof(buffer)-1);
            if(n > 0)
            {
                buffer[n] = '\0';
                std::cout<<"client say# "<<buffer<<std::endl;
            }
            else if(n == 0)
            {
                std::cout<<"client quit!"<<std::endl;
                break;
            }
            else
            {
                ERR_EXIT("Read");
                break;
            }
        }
    }
    void Write()
    {
        std::string message;
        int cnt = 1;
        pid_t id = getpid();
        while(1)
        {
            std::cout<<"Please Enter#"<<std::endl;
            std::cout<<"message number "<< cnt++ << " [" << id << "]: ";
            getline(std::cin,message);
            write(_fd,message.c_str(),message.size());
        }
    }
    void Close()
    {
        if(_fd > 0)
            close(_fd);
    }
private:
    int _fd; //管道文件的读端或写端
    std::string _path;
    std::string _name;
    std::string _filename;
};
宏 ERR_EXIT(m) 的作用是打印自定义提示字符串 m 和系统自带错误描述,EXIT_FAILURE 是标准宏,值为 1,代表程序异常退出。宏只能写成一行,为了方便阅读需要借助续行符 \ 。 do {...}while(0) 是 C 语言多行宏的标准万能写法,正常while后是需要加 ; ,但这里不加,方便宏进行文本替换时不会多一个 ; 。
在server.cc中创建管道文件,打开管道文件并读取管道传过来的内容,在client.cc中打开管道文件,往管道写内容。
//server.cc
#include "common.hpp"
int main()
{
    NamedFifo fifo(PATH, FILENAME);
    FileOper reader(PATH, FILENAME);
    reader.OpenForRead();
    reader.Read();
    reader.Close();
    return 0;
}
//client.cc
#include "common.hpp"
int main()
{
    FileOper writer(PATH, FILENAME);
    writer.OpenForWrite();
    writer.Write();
    writer.Close();
    return 0;
}
打开两个终端,先运行server。此时由于写端未打开,server进程阻塞在open()。
运行client,实现两个进程间的通信。
system V共享内存
System V 是一套 Unix 标准,Linux 内核专门实现了这套标准对应的 IPC 模块,共享内存就是 System V IPC 其中一种通信方式。
共享内存工作流程(图中 step 逻辑)
OS 在物理内存开辟一块内存空间(共享内存块);
通过页表,把同一块物理内存,分别映射到 进程 A 的虚拟地址空间、进程 B 的虚拟地址空间;
进程 A、B 操作自己虚拟地址上对应的内存,实际读写的是同一块物理内存,从而完成通信。
普通文件 IO / 管道需要在内核缓冲区、用户缓冲区之间拷贝数据;共享内存没有数据拷贝,直接读写物理内存,这是 Linux 下最快 IPC。
系统中可以同时创建多块独立共享内存,供多组进程通信。操作系统需要管理所有共享内存,因此,每一块共享内存,在内核里都有一个对应的内核结构体对象,用来描述这块共享内存信息和关联对应的物理内存。
shmget 是系统调用,用于创建共享内存。成功返回共享内存标识符shmid(非负),失败返回-1并设置错误码。
参数key是非负整数,是共享内存的名字,内核中每一块共享内存,都绑定唯一的 key;多个进程依靠相同 key定位同一个内核 IPC 对象。key由用户层生成,进程之间互相约定,用来查询内核,找到 IPC 对象。
key是进程之间约定、对外用来查找共享内存的外部标识;shmid是进程拿到 key 找到共享内存后,进程内操作使用的内部id。
参数size是需要创建的共享内存的大小(字节)。只有新建共享内存时,size 才生效;打开已有共享内存时,size 会被内核忽略。内核创建共享内存时,大小必须是4KB的整数倍,如果size为4097byte,内核会创建8KB,而用户层仍是4097byte,应用可用范围仍然只有 4097 字节,超出部分属于越界访问,不能读写。
参数shmflg是标志位,与open()类似,有两个核心宏,IPC_CREAT 和 IPC_EXCL 。IPC_CREAT 如果内核中不存在该 key 对应的共享内存 → 创建;如果已经存在 → 直接打开,返回共享内存 id。IPC_CREAT | IPC_EXCL 如果共享内存不存在 → 创建;如果已经存在 → 函数直接报错返回 -1。
shmget 只是创建共享内存内核对象,还未建立虚拟地址映射。
ftok 可以在用户空间计算出一个 key_t 类型的 key。失败返回-1。
参数pathname必须是磁盘上真实存在、可访问的文件路径,可以是任意路径,文件删除也会导致算出的key变化。
参数proj_id只有低 8 位有效,取值范围 0 ~ 255。
key和pro_id常用16进制表示。
命令 ipcs -m 查看共享内存。
命令 ipcrm -m shmid 手动删除 IPC 内核对象。System V 共享内存拥有内核持久性,进程全部退出,资源不会自动释放。执行 ipcrm -m shmid 只是标记删除,只要还有进程关联共享内存(nattch≠0),内存就依旧存在,所有进程 detach 之后才释放。
void *shmat(int shmid, const void *shmaddr, int shmflg) 将内核中的共享内存,挂载(映射)到当前进程虚拟地址空间。其中at即attach。成功返回共享内存在当前进程内的起始虚拟地址,失败返回(void*)-1。
参数shmid是shmget成功返回的共享内存标识符。
参数shmaddr是指定映射的起始虚拟地址,工程标准写法是传入NULL,让操作系统自动选择合适的虚拟地址。
参数shmflg是挂载标志,控制当前这一次映射的访问模式,一般填0,表示可读可写。
进程通过shmat挂载共享内存后,挂载计数nattch会+1。
shmdt 即shmdt = shared memory detach,作用是解除当前进程虚拟地址和共享物理内存之间的页表映射。成功返回0,失败返回-1。
参数shmaddr是shmat返回的虚拟内存起始指针。
shmdt 只会断开本进程的映射,不会删除内核里的共享内存对象,挂载计数nattch会 -1。
一个共享内存可以被多个进程 shmat;A 进程 shmdt,完全不影响 B 进程继续使用。
shmctl 即shmctl = shared memory control ,是共享内存控制函数,相当于共享内存的万能操作接口,依靠第二个参数决定执行什么功能。失败返回-1。
参数shmid是shmget 得到的共享内存 ID。
参数op是控制命令。
参数buf是结构体指针,部分命令需要,不需要时填NULL。struct shmid_ds 是内核用来描述一块 System V 共享内存段所有信息的数据结构体。
struct shmid_ds {
    uid_t shm_perm.uid;  // 所有者
    mode_t shm_perm.mode;// 权限
    size_t shm_segsz;    // 共享内存大小
    pid_t shm_cpid;      // 创建进程pid
    pid_t shm_lpid;      // 最后一次shmat/shmdt的进程pid
    shmatt_t shm_nattch; // 当前挂载进程数量 nattch
    // ...时间相关成员
};
op常用命令 IPC_RMID 、IPC_STAT、IPC_SET 。
IPC_RMID 标记删除共享内存,等价于 ipcrm -m shmid 。
IPC_STAT 获取共享内存属性信息。
struct shmid_ds shminfo;
shmctl(shmid, IPC_STAT, &shminfo);
IPC_SET 修改共享内存属性。
测试代码如下。
用共享内存实现server与client进程间通信。
//Makefile
.PHONY:all
all:server client
server:server.cc
g++ -o $@ $^ -std=c++11
client:client.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f server client
//common.hpp
#pragma once
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)
//shm.hpp
#pragma once
#include <sys/shm.h>
#include "common.hpp"
const int g_default_id = -1;
const int g_size = 4096;
const int g_projid = 0x66;
const int g_mode = 0666;
const std::string g_pathname = ".";
#define CREATER "creater"
#define USER "user"
class Shm
{
public:
    Shm(const std::string& pathname,int projid,const std::string& usertype)
        :_shmid(g_default_id)
        ,_size(g_size)
        ,_start_mem(nullptr)
        ,_usertype(usertype)
    {
        _key = ftok(pathname.c_str(),projid);
        if(_key < 0)
            ERR_EXIT("ftok");
        else
            std::cout<<"ftok success"<<std::endl;
        if(_usertype == CREATER)
        {
            Create();
        }
        else if(_usertype == USER)
        {
            Get();
        }
        else
            std::cout<<"usertype error"<<std::endl;
        Attach();
    }
    ~Shm()
    {
        std::cout<<"~shm()"<<std::endl;
        if(_usertype == CREATER)
            Destroy();
    }
    void* VirtualAddr() //共享内存的起始虚拟地址
    {
        printf("_start_mem:%p\n",_start_mem);
        return _start_mem;
    }
    int Size() //共享内存大小
    {
        return _size;
    }
    void Attibution() //查看属性
    {
        struct shmid_ds ds;
        int n = shmctl(_shmid,IPC_STAT,&ds);
        if(n < 0)
            ERR_EXIT("shmctl");
        printf("key: 0x%x\n",ds.shm_perm.__key);
        printf("segsz: %ld\n",ds.shm_segsz);
    }
private:
    void CreateShmHelper(int shmflg)
    {
        printf("_key: 0x%x\n",_key);
        _shmid = shmget(_key,_size,shmflg);
        if(_shmid >=0 )
            std::cout<<"_shm: "<<_shmid<<std::endl;
        else
            ERR_EXIT("shmget");
    }
    void Create()
    {
        CreateShmHelper(IPC_CREAT | IPC_EXCL | g_mode);
    }
    void Get()
    {
        CreateShmHelper(IPC_CREAT);
    }
    void Attach() //挂载
    {
        _start_mem = shmat(_shmid,nullptr,0);
        if((long long)_start_mem < 0)
            ERR_EXIT("shmat");
        else
            std::cout<<"shmat success"<<std::endl;
    }
    void Detach() //解除挂载
    {
        int n = shmdt(_start_mem);
        if(n < 0)
            ERR_EXIT("shmdt");
    }
    void Destroy()
    {
        if(_shmid == g_default_id)
            return;
        Detach();
        int n = shmctl(_shmid,IPC_RMID,nullptr);
        if(n < 0)
            ERR_EXIT("shmctl");
        else
            std::cout<<"delete _shmid: "<<_shmid<<std::endl;
    }
    int _shmid;
    key_t _key;
    int _size;
    void* _start_mem;
    std::string _usertype;
};
//server.cc
#include "shm.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, CREATER);
    std::cout<<"共享内存大小: "<<shm.Size()<<std::endl;
    shm.Attibution();
    shm.VirtualAddr();
    sleep(10);
    return 0;
}
//client.cc
#include "shm.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, USER);
    sleep(5);
    return 0;
}
shmid会持续递增,即使shmid对应的共享内存已经被释放。
修改server.cc和client.cc,client每2秒打印字母从A到K,每次追加打印一对,server每1秒读一次共享内存。
//server.cc
#include "shm.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, CREATER);
    std::cout<<"共享内存大小: "<<shm.Size()<<std::endl;
    shm.Attibution();
    shm.VirtualAddr();
    char* mem = (char*)shm.VirtualAddr();
    while(1)
    {
        if((long long)mem < 0)
            ERR_EXIT("shm.VirtualAddr()");
        else
            printf("%s\n",mem);
        sleep(1);
    }
    return 0;
}
//client.cc
#include "shm.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, USER);
    char* mem = (char*)shm.VirtualAddr();
    int i=0;
    for(char c = 'A';c <= 'K';c++,i+=2)
    {
        sleep(1);
        mem[i] = c;
        mem[i+1] = c;
        sleep(1);
        mem[i+2] = '\0';
    }
    return 0;
}
可以看到,server是一直读的,所以会出现两行相同字符串,这说明共享内存天然不提供同步。管道、消息队列 自带同步阻塞;共享内存仅仅是一块内存缓冲区,内核不提供任何读写保护。即不管写端client是否往共享内存中写数据,读端server都会一直读,sleep 无法保证时序,高并发下会读到半写的脏数据。
下面的解决方案是采用命名管道 FIFO 充当通知信号。
//Fifo.hpp
#pragma once
#include "common.hpp"
#define PATH "."
#define FILENAME "fifo"
//创建管道文件
class NamedFifo
{
public:
    NamedFifo(const std::string& path, const std::string& name)
        :_path(path),_name(name)
    {
        _filename = path + "/" + name;
        //新建命名管道
        umask(0);
        int n = mkfifo(_filename.c_str(),0666);
        if(n < 0)
            ERR_EXIT("mkfifo");
        else
            std::cout<<"mkfifo success"<<std::endl;
    }
    ~NamedFifo()
    {
        int n = unlink(_filename.c_str());
        if(n < 0)
            ERR_EXIT("unlink");
        else
            std::cout<<"remove "<<_filename<<" success"<<std::endl;
    }
private:
    std::string _path;
    std::string _name;
    std::string _filename;
};
//文件操作
class FileOper
{
public:
    FileOper(const std::string& path, const std::string& name)
        :_path(path),_name(name),_fd(-1)
    {
        _filename = path + "/" + name;
    }
    ~FileOper()
    {}
    void OpenForRead()
    {
        _fd = open(_filename.c_str(),O_RDONLY);
        if(_fd < 0)
            ERR_EXIT("OpenForRead");
        else
            std::cout<<"open success"<<std::endl;
    }
    void OpenForWrite()
    {
        _fd = open(_filename.c_str(),O_WRONLY);
        if(_fd < 0)
            ERR_EXIT("OpenForWrite");
        else
            std::cout<<"open success"<<std::endl;
    }
    int i = 0, j = 0;
    void WakeUp()
    {
        char c = 0;
        int n = write(_fd,&c,1);
        if(n > 0)
            printf("尝试唤醒, i = %d\n",i++);
    }
    bool Wait()
    {
        char c;
        int n = read(_fd,&c,1);
        if(n > 0)
        {
            printf("唤醒成功: j = %d\n",j++);
            return true;
        }
        return false;
    }
    void Close()
    {
        if(_fd > 0)
            close(_fd);
    }
private:
    int _fd; //管道文件的读端或写端
    std::string _path;
    std::string _name;
    std::string _filename;
};
//server.cc
#include "shm.hpp"
#include "Fifo.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, CREATER);
    std::cout<<"共享内存大小: "<<shm.Size()<<std::endl;
    shm.Attibution();
    shm.VirtualAddr();
    NamedFifo fifo(PATH, FILENAME); //创建管道文件
    FileOper reader(PATH, FILENAME);
    reader.OpenForRead(); //打开管道读端
    char* mem = (char*)shm.VirtualAddr();
    while(1)
    {
        if((long long)mem < 0)
            ERR_EXIT("shm.VirtualAddr()");
        if(reader.Wait())
            printf("%s\n",mem);
        else
            break;
        //sleep(1);
    }
    return 0;
}
//client.cc
#include "shm.hpp"
#include "Fifo.hpp"
int main()
{
    Shm shm(g_pathname, g_projid, USER);
    FileOper writer(PATH, FILENAME);
    writer.OpenForWrite(); //打开管道写端
    char* mem = (char*)shm.VirtualAddr();
    int i=0;
    for(char c = 'A';c <= 'K';c++,i+=2)
    {
        sleep(1);
        mem[i] = c;
        mem[i+1] = c;
        sleep(1);
        mem[i+2] = '\0';
        writer.WakeUp(); //完成写入后尝试唤醒读端server
    }
    return 0;
}
其中,client和server的_start_mem不一样是因为两个进程挂载后虚拟地址对共享内存的映射不同。
system V消息队列
消息队列是 System V IPC 进程间通信方式,队列资源维护在操作系统内核空间,用来实现进程之间传递带类型的数据块。
队列节点可以简化理解如下:
struct node
{
    int type;        // 消息类型标签
    char buffer[];   // 消息数据缓冲区
    struct node *next; // 链表指针,串联多条消息
};
type是用于区分消息类型的标签。buffer存放消息内容数据。
发送方进程构造消息,调用接口把消息送入内核里的消息队列。OS在内核维护消息队列,内核还会用 struct msqid_ds 结构体管理整个消息队列的属性。接收方进程接收时可以按 type 筛选消息,比如只读取 type=2 的消息,不用严格遵守先进先出。消息队列支持按类型选择性读取。
消息队列可利用消息类型 type 区分请求 / 应答方向,从而支持单条队列双向通信。但规范做法还是用双队列实现。
多个进程依靠key找到同一个消息队列,不同进程传入相同 key,就能打开、访问内核中同一个消息队列资源。这与共享内存相似。
消息队列的生命周期随内核,必须手动调用接口删除,或者重启操作系统才会被清理。
因为消息队列也属于System V标准,所以消息队列与共享内存有许多相似性。命令 ipcs -q 可以查看消息队列,ipcs 能直接查看共享内存、消息队列、信号量。
ipcrm -q msqid 删除消息队列。
同样用 ftok() 生成消息队列的key。
msgget 创建消息队列。成功返回消息队列标识符msqid,失败返回-1。
参数key由ftok生成。
参数msgflg是标志位,传入IPC_CREAT,则不存在则创建,已存在直接打开。传入IPC_CREAT | IPC_EXCL,则不存在才创建,如果队列已经存在,调用直接报错。后面还要按位或上权限位,例如 0666 。
msgctl 与 shmctl 同理,是消息队列控制函数。失败返回-1。
参数op传入 IPC_RMID ,buf传入NULL,删除消息队列。op传入 IPC_STAT ,把内核中该消息队列的属性,拷贝到传入的 struct msqid_ds *buf 中,读取队列信息。
msgsnd 发送消息。成功返回0,失败返回-1。
const void msgp[.msgsz] 等价于 const void *msgp ,msgp 指向自定义消息结构体 struct msgbuf 。
mtype 是消息类型,mtext 消息正文缓冲区,柔性数组。msgbuf 是用户态模板,内核不使用这个结构体,发送时数据拷贝到内核struct msg_msg 。发送消息时创建msgbuf对象,填入消息类型和消息内容,传入msgsnd。
参数msgsz是消息正文长度,只算 mtext 大小。
参数msgflg常用 0 ,表示阻塞模式。
msgrcv 接收消息。成功返回拷贝到 mtext 缓冲区的字节数量,失败返回-1。除了msgtyp,其余参数与msgsnd()类似。
参数msgtyp让消息队列相比管道具备按类型筛选消息的能力。
msgtyp == 0:读取队列最前端第一条消息(FIFO 顺序,任意类型)
msgtyp > 0:读取队列中第一条 mtype == msgtyp 的消息
msgtyp < 0:读取队列中 mtype ≤ |msgtyp| 的最小类型消息(较少使用)
system V信号量
概念铺垫:
多个进程可访问的公共资源称为共享资源;受保护的共享资源叫临界资源。
进程中访问临界资源的代码段称为临界区,其余代码是非临界区。
互斥:同一时刻只允许一个进程访问临界资源。
同步:多个进程访问临界资源需要遵循预定先后次序。
保护临界资源的本质是保护临界区代码。
锁操作必须具备原子性,避免并发下锁失效。
System V 信号量是内核提供的、用于实现进程间同步与互斥的机制,常配合共享内存使用。
临界资源是被保护起来的共享资源。普通共享资源不加保护,并发访问会出错;一旦我们通过锁、信号量约束访问,这份资源就称为临界资源。
原子性:操作要么完整做完,要么完全不执行,不会中途被打断。申请锁、释放锁的操作必须是原子操作。
信号量就是操作系统提供的同步互斥工具:通过 P 操作(申请资源 / 加锁)、V 操作(释放资源 / 解锁),实现临界区保护,解决共享内存并发访问混乱问题。
共享内存 + 信号量 = 高性能进程间通信方案。共享内存负责传输数据;信号量负责保护数据,防止并发冲突。
操作系统提供的信号量、互斥锁,底层由内核保证 P/V 操作原子性,用户自己无法实现。
信号量中有内核维护的计数器,代表可用临界资源数量。所有进程想要访问临界资源,必须先执行 P 操作申请信号量。执行P操作,如果信号量sem>0,计数器自减,进程拿到资源,继续执行;如果sem == 0,没有可用资源,进程阻塞,进入内核等待队列休眠。执行V操作,释放资源,sem++,计算器自增1。
信号量分类
二元信号量(互斥锁):信号量初值 = 1,最多只有 1 份资源可用,同一时刻仅允许一个进程进入临界区。专门用来实现互斥。
多元信号量(计数信号量):信号量初值 > 1,允许最多 N 个进程同时访问资源,用于控制并发上限。
信号量生命周期随内核,需要手动调用接口删除。
信号量本身是共享资源,多个进程要操作同一个信号量,必须通过 semget() 获取同一个 semid。内核负责维护信号量、计数器、进程等待队列。
信号量只做同步、互斥通知,不传输业务数据。
命令 ipcs -s 查看信号量,ipcrm -s semid 删除信号量。
信号量也属于system V标准,与共享内存、消息队列类似。
semget 创建信号量集合。成功返回信号量集合标识符semid,失败返回-1。System V 没有独立的 “单个信号量”,内核提供的最小操作单元是信号量集合 semaphore set。一个集合内部,可以存放1个或多个相互独立的信号量。
参数key由ftok获得。多个进程约定同一个 key,才能找到内核里同一个信号量集。
参数nsems是信号量集合里面包含独立信号量的个数。
参数semflg与shmget()和msgget()的标识符参数一样。
semop 执行 P/V 原子操作。成功返回0,失败返回-1。
参数semid指定要操作哪一组信号量集合。
参数sops为操作数组首地址,即 struct sembuf 数组,每一个 struct sembuf 代表一次针对集合内某个信号量的操作。
struct sembuf{
    unsigned short sem_num;  // 当前要操作集合里第几个信号量,从0开始编号
    short sem_op;
    // sem_op = -1 → P操作(申请资源 sem--)
    // sem_op = +1 → V操作(释放资源 sem++)
    short sem_flg; // 常用:0(阻塞等待)、IPC_NOWAIT(非阻塞)
};
参数nsops本次要执行多少个 struct sembuf 操作(数组元素个数)。常见取值 1:单次只执行一个 P/V 操作。
semctl 信号量控制。失败返回-1。
参数 semnum 是信号量集合内部的下标(从 0 开始),指定本次操作集合中第几个信号量。部分命令(如IPC_RMID、IPC_STAT)作用于整个信号量集合,此时semnum参数会被内核忽略,随便填 0 即可。
参数cmd是控制命令。SETVAL 设置集合内某一个信号量的计数值,semget() 仅仅创建信号量集合,不会初始化计数器,必须用 SETVAL 初始化。GETVAL 获取单个信号量的当前计数值。GETALL 一次性读取集合内所有信号量的值到数组。SETALL 一次性批量设置集合内全部信号量的值。IPC_RMID 、IPC_STAT、 IPC_SET 与共享内存、消息队列类似。
可变参数 … 类型为 union semun,根据 cmd 决定是否需要传入。
共享内存、消息队列、信号量在内核中被当作同一类 IPC 资源统一管理,都拥有 ipc_perm 结构。
信号量和通信有什么关系?
多个进程依靠同一个 key定位同一个内核信号量集合(System V 解决进程间找到同一信号量的问题)。
信号量不传输业务数据!
通信分为两类:
数据通信:共享内存、消息队列(传递数据);
同步互斥通信:信号量(只做通知、约束执行顺序,不传递数据)。
————————————————
版权声明:本文为CSDN博主「拳里剑气」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2501_90377063/article/details/163415588
上一篇 Ssd可靠性指标
下一篇 VLAN间路由怎么选?是时候该升级一波了