【Linux手册】信号量与建造者模式:以 PV 操作保证并发安全,分步组装构建复杂对象

发布于:2025-09-11 ⋅ 阅读:(14) ⋅ 点赞:(0)

前言

关于进程间通信的方案有很多,可以使用内存级文件——管道来进行通信,也可以直接在内存中开辟两个进程共用的内存空间——共享内存,还可以使用全双工的消息队列来进行通信。

本篇文章我们将讲解一种特殊的通信方式——信号量。
本文将分为4个部分来介绍信号量:

  1. 信号量是什么?
  2. 信号量的接口使用;
  3. 信号量简单使用;
  4. 基于建造者模式对信号量接口进行封装。

信号量是什么

先说结论:信号量本质上是一把计数器。

什么意思,在前几个进程间通信方式中都是直接或间接的开辟公共资源,让两个不同的进程进行交互,这里怎么就成计数器了???

确实,信号量的使用和其他System V通信的目的不一样,像共享内存,消息队列都是直接将要告诉对方的数据写入共享资源中或发送出去让对方取;

而信号量不是直接让两个进程进行交流,而是通过某种方式告诉双方我在干什么,你能干什么,举个例子:
当两个进程都要向显示器上打印数据的时候,如果让两个进程同时打印,显示器上数据就会出现乱序,我们就不知道那个是那个打印的了,所以要进行控制,当一个进程在打印的时候,另一个进程就不应该打印,而是等前一个进程打印往后,再打印。

***上面例子中就需要双方能够进行通信,让对方直到,我是否在打印数据,而此时显示器文件就是共享资源,我们希望双方能够知道在要进行打印的时候,能否使用共享资源。

所以信号量就是用来告诉进程是否可以访问共享资源的。

信号量是一把计数器,意思就是其记录着一块共享资源有多少份,能够被多少进程进行访问,如果有进程访问,就将计数器中数量-1,访问完了再+1,如果信号量变成0表示,不能再进行访问了,要等其他进程访问完才能访问。

因此,上面对显示器资源的访问就可以使用一个值为1的信号量,具体细节如下:

  1. 当一个进程要打印时,先申请信号量,如果信号量不为0,就说明没有进程在使用,申请成功,信号量-1;如果信号量为0,说明有进程在1访问,就一直阻塞在申请信号量的位置,知道信号量被归还,即信号量不为0时,才继续申请;
  2. 当进程打印往后,将信号量规范,即信号量+1;

两个进程都要能获取信号量,那么说明信号量也是共享资源,我们知道对数据的加减操作,也不是原子的,所以有没有可以对信号的加减出错???*

确实对数据的加减操作是不原子的,确实有可能了多个进程同时对信号量进行加减操作,所以我们不直接对信号量进行加减操作,而是使用系统提供的接口,来保证对信号量的使用不会出错,具体在后面接口的使用进行介绍。

PS:原子性:如果一件事,只有做和没做两种情况,而没有正在做,就将其称为原子的。

总结:

  • 要访问共享资源先申请计数器,申请成功有资格访问;
  • 申请计数器,实际上就是对资源的预定机制,只有预定成功的可以进行访问;
  • 信号量可以有效控制访问临界资源的进程数量;

信号量接口

创建信号量

创建信号量:int semget(key_t key , int nsems , int semflg)

  1. 返回值信号量标识符,用来区分不同的信号量;
  2. 第二个参数nsems创建一个信号量集中信号量的个数,即要创建多少个信号量,可以同时创建多个来管理不同的共享资源,注意此处区分信号量值,信号量值只用来管理一块共享资源最多可以有多少个进程同时进行访问;
  3. 第三个参数,选项,常见的有::IPC_CREAT创建信号量,如果已经存在该信号量就直接返回信号量描述符,否则创建信号量;IPC_CREAT | IPC_EXIT创建信号量,如果已经存在信号量,直接报错,保证返回的信号量是新创建的;
  • 第一个参数key是数字,该数字在操作系统内核中是唯一的,用key来区分内核中不同的的信号量。
  • 两个进程如果key值是相同的就说明要访问同一个信号量。

key值在操作系统内是唯一的,那我在传参的时候如何传,我怎么知道自己的key有没有被使用???

在操作系统中该key值不需要用户来做,操作系统也提供了接口来让我们设置key值:

key_t ftok(const char* pathname , int proj_id)

  • 返回值,返回key值,失败返回-1;
  • 第一个参数:一个字符串是表示一个有效路径,第二个参数一个数字。

ftok()是一个算法,通过一个有效路径字符串和一个数字来获得一个冲突最小的key值。
只能说冲突最小,也有可能出现冲突,也就是说ftok()产生的key可能是已经存在的了,此时就会返回-1,通过调整路径或后面的数字重新尝试。

控制信号量

对于控制信号量,只有一个接口:int semctl(int semid , int semnum ,int op , ...)

  • 第一个参数就是信号量集的描述符,要哪一个信号量集进行操作;
  • 第二个参数就是要对该信号量具体大哪一个信号量进行操作,操作的下标是从0开始的;
  • 第三个参数就是要进行的操作选项,以下举几个常见的选项。

IPC_RMID:释放信号量资源;

SETVAL:设置信号值,此处需要通过第四个参数来完成,并且第四个参数必须是union senum一个联合体,第一个成员就是设置的信号量值,该联合体库中没有提供,需要自己定义。

	union semun { 
	int val; /* 用于 SETVAL */ 
	struct semid_ds *buf; /* 用于 IPC_STAT、IPC_SET */ 
	unsigned short *array; /* 用于 SETALL、GETALL */ 
};

信号量的加减

信号量的加减使用一个接口:int semop(int semid , struct sembuf *sops, size_t nsops)

  1. 返回值表示是否成功,0表示成功,-1表示失败;
  2. 第一个参数:信号量集描述符;
  3. 第二个参数一个结构体:
/* semop system calls takes an array of these. */
struct sembuf {
    unsigned short  sem_num;    /* semaphore index in array */
    short       sem_op;     /* semaphore operation */
    short       sem_flg;    /* operation flags */
};
  1. 第一个成员要对信号量集中的哪一个信号量进行操作;
  2. 第二个成员要进行申请还是归还,1表示规范信号量,-1表示释放信号量;
  3. 第三个参数依旧是选项,IPC_NOWAIT表示如果没有信号量了就直接返回-1,不进行阻塞等待;SEM_UNDO表示如果一个进程申请了信号量,突然异常终止了,让内核自动撤销原来的操作,防止信号量资源泄露。
  4. 第三个参数要执行的操作数量,即表示 struct sembuf 结构体数组中包含的操作个数。

信号量的简单使用

关于操作,上面已经讲完了,现在简单使用以下信号量,来让两个进程有序的向显示器上打印数据。此处为了简单我们直接使用,二元信号量。

先对信号量的接口进行封装,创建信号量和获取信号量:

创建信号量和获取信号量都需要向获取一个key值,所以对key获取进行封装:

const std::string defaultpath_name = "/tmp";
const int defaultproj_id = 0x122;

class Semaphore
{   
 key_t Get_Key(const std::string &path_name, const int &proj_id)
    {
        key_t key = ftok(path_name.c_str(), proj_id);
        if (key < 0)
        {
            std::cerr << "ftok error" << " , int file" << __FILE__ << " , int line" << __LINE__ << std::endl;
            exit(1);
        }
        return key;
    }
private:
    int semid_;
};

创建信号量和获取信号量都是通过semget(),并且只是选项不一样而已,所以可以直接使用一个函数,即用于创建又用于获取,只不过选项让外界来传:

const int SEM_CREATE = IPC_CREAT | IPC_EXCL | 0666;
const int SEM_GET = IPC_CREAT;

class Semaphore
{   
    // 用户指定是进行创建还是获取信号量
    void CG_Sem(int key, int flag)
    {
        semid_ = semget(key, 1, flag);
        if (semid_ < 0)
        {
            std::cerr << "semget error" << " , int file" << __FILE__ << " , int line" << __LINE__ << std::endl;
            exit(2);
        }
    }
private:
    int semid_;
};

在创建完,还要对信号量进行初始化:

const int SEM_CREATE = IPC_CREAT | IPC_EXCL | 0666;
const int SEM_GET = IPC_CREAT;

class Semaphore
{   
    void Init_Sem()
    {
        union semun
        {
            int val;               /* Value for SETVAL */
            struct semid_ds *buf;  /* Buffer for IPC_STAT, IPC_SET */
            unsigned short *array; /* Array for GETALL, SETALL */
            struct seminfo *__buf; /* Buffer for IPC_INFO
                                      (Linux-specific) */
        };
        union semun arg;
        arg.val = 1;
        int n = semctl(semid_, 0, SETVAL, arg);
        if (n < 0)
        {   
            std::cerr << "semctl error" << " , int file" << __FILE__ << " , int line" << __LINE__ << std::endl;
            exit(3);
        }
    }
private:
    int semid_;
};

以上工作都准备好后,可以编写创建和获取的函数了:

class Semaphore
{   
public:
    // 创建信号量
    void Creat_Sem(const std::string &path_name = defaultpath_name, const int &proj_id = defaultproj_id)
    {
        key_t key = Get_Key(path_name, proj_id);
        CG_Sem(key,SEM_CREATE);
        // 初始化信号量
        Init_Sem();
    }

    // 获取信号量
    void Get_Sem(const std::string &path_name = defaultpath_name, const int &proj_id = defaultproj_id)
    {
        key_t key = Get_Key(path_name, proj_id);
        CG_Sem(key, SEM_GET);
    }
private:
    int semid_;
};

紧接着就是信号量的销毁:

class Semaphore
{   
public:
   // 销毁信号量
    void Destory_Sem()
    {
        int n = semctl(semid_ , 0 , IPC_RMID);
        if(n < 0)
        {
            std::cerr << "semctl error" << " , int file" << __FILE__ << " , int line" << __LINE__ << std::endl;
            exit(6);
        }
    }
private:
    int semid_;
};

此处就可以实现信号量的PV操作了,为了方便,此次将其封装为一个:

class Semaphore
{   
    void PV(int flag)
    {
        struct sembuf sbf;
        sbf.sem_num = 0;
        sbf.sem_op = flag;
        sbf.sem_flg = SEM_UNDO;
        int n = semop(semid_, &sbf, 1);
        if (n < 0)
        {
            std::cerr << "semop error" << " , int file : " << __FILE__ << " , int line : " << __LINE__ << std::endl;
            exit(5);
        }
    }
public:
    // P操作,申请一个信号量
    void P()
    {
        PV(-1);
    }
    // V操作,归还信号量
    void V()
    {
        PV(1);
    }

private:
    int semid_;
};

以上就是对信号量接口的封装,现在写一个test代码进行检验以下,看信号量是否能使用,此处为了方便,我们直接让父子进程来使用信号量:

int main()
{
    Semaphore sem;
    sem.Creat_Sem();

    srand((unsigned)time(nullptr));

    std::cout << std::unitbuf;
    int pid = fork();
    if (pid == 0)
    {
        sem.Get_Sem();
        for (int i = 0; i < 10; i++)
        {
            sem.P();

            std::cout << "A";
            usleep(rand() % 100860);
            std::cout << "A";
            usleep(rand() % 1860);
            sem.V();
        }

        exit(0);
    }

    for (int i = 0; i < 10; i++)
    {
        sem.P();

        std::cout << "B";
        usleep(rand() % 100860);
        std::cout << "B";
        usleep(rand() % 1860);
        sem.V();
    }
    waitpid(pid , nullptr , 0);
    sem.Destory_Sem();

    return 0;
}

以上代码中通过信号量,保证复制进程只有一个可以向显示器上进行打印,也就是说父进程的两个B都打印完了,子进程才能打印,反过来也一样;也就是说AA是连续的,BB也是连续的。下面看现象:请添加图片描述

建造者模式

在上面代码中有很多地方都是硬编码的,没有向外提供接口,比如信号量权限,信号量集中信号量的数目…

但是如果将接口直接暴露出来就会导致,初始化长度很长,用户记住每一参数要传递什么,而且有些参数用户希望使用默认值,但是也要传递,增加了使用的成本。

为了解决这一问题,我们使用一种新的设计模式:建造者模式。

建造者模式一种需要设计4个类:

  1. 产品类,即我们最终希望使用的类,我们通过该类来调用我们希望的操作;
  2. 抽象建造者接口:用来规范个接口的名称,一般值进行函数声明,让派生类进行函数定义;
  3. 具体的建造者:实现抽象建造者的接口,负责具体的产品部件构建和组装;
  4. 指挥者:根据建造者的接口,将产品组装起来,就是调用建造者的接口根据不同的需求组装不同的产品。

产品类

首先编写产品类,产品类主要负责PV操作,以及释放信号量;

  • 关于PV操作,与上面的代码一直,此处不再赘述;
  • 关于释放信号量,只有创建信号量的进程才能进行释放,直接获取信号量的进程不能进行释放,所以要加一个判断条件。
class Semaphore
{
    void PV(int pos, int flag)
    {
        struct sembuf sem_b;
        sem_b.sem_num = pos;
        sem_b.sem_op = flag;
        sem_b.sem_flg = SEM_UNDO;
        int n = semop(semid_, &sem_b, 1);
        if(n < 0)
        {
            std::cerr << "semop error" << " , int file : " << __FILE__ << " , int line : " << __LINE__ << std::endl;
            exit(4);
        }
    }

public:
    Semaphore(int semid , int flag)
        : semid_(semid) , flag_(flag)
    {
    }

    void P(int pos)
    {
        PV(pos, -1);
    }

    void V(int pos)
    {
        PV(pos, 1);
    }

    int Get_id()   // 获取信号量id
    {
        return semid_;
    }

    ~Semaphore()
    {
        if(flag_ == SEM_CRAEY) // 只有创建信号量的semid才需要删除
        {
            semctl(semid_, 0, IPC_RMID);
            std::cout << "semaphore remove success" << std::endl;
        }
    }

private:
    int semid_;
    int flag_;
};

抽象建造者接口

抽象建造者接口,主要负责将函数的声明,而不进行定义,只是为了保证指针统一,都是由基类指向派生类的:

  • 成员变量,需要包含所有在创建信号量时需要使用到的变量,包含调用ftok()semget()等各种参数,可以提供一些缺省值。
  • 成员函数,不仅需要提供对系统接口的封装,还要对外提供接口,让外部可以修改构建信号量的各种参数。
  
const std::string defaultfile_name = "/tmp";
const int defaultproj_id = 0x34;
const int defaultperm = 0666;
const int defaultnum = 1;

// 抽象建造者
class Bulider
{
public:
    // 设置key值的参数
    virtual Bulider &Set_Key(const std::string &file_name, const int &proj_id) = 0;
    // 创建key值
    virtual int Build_Key() = 0;

    // 构建权限
    virtual Bulider &Set_Perm(const int &perm) = 0;
    // 信号集中信号的个数
    virtual Bulider &Set_Num(const int &num) = 0;
    // 设置初始值
    virtual Bulider &Set_Value(const std::vector<int> &value) = 0;

    // 初始化信号量
    virtual void Init_EachSem(int pos, int value) = 0;
    // 初始化信号集
    virtual void Init_ALLSem() = 0;
    // 构建函数
    virtual void Create_Sem(int flag) = 0;
    // 获取信号量
    virtual std::shared_ptr<Semaphore> Get_Sem() = 0;

protected:
    std::string file_name_ = defaultfile_name;    
    int proj_id_ = defaultproj_id;
    key_t key_;

    int perm_ = defaultperm;
    int num_ = defaultnum;
    std::vector<int> value_;
    std::shared_ptr<Semaphore> sem_;
};

具体的建造者

此次就要对继承下来的接口进行实现:

首先就就是修改ftok()函数的参数,和返回key值得函数:

  • 通过Set_Key向外提供修改路径名和项目ID参数的接口;
  • Build_Key用来在获取/创建信号量时,返回key值。
// 具体的建造者
class SemaphoreBulider : public Bulider
{
public:
    SemaphoreBulider() = default;
    // 设置key值的参数
    virtual Bulider &Set_Key(const std::string &file_name, const int &proj_id) override
    {
        file_name_ = file_name;
        proj_id_ = proj_id;
        return *this;
    }

    // 创建key值
    virtual int Build_Key()
    {
        int k = ftok(file_name_.c_str(), proj_id_);
        std::cout << "file_name : " << file_name_ << " , proj_id : " << proj_id_ << " , key : " << k << std::endl;
        if (k < 0)
        {
            std::cerr << "ftok error" << " , int file : " << __FILE__ << " , int line : " << __LINE__ << std::endl;
            exit(1);
        }
        return k;
    }
};

紧接着就就是对创建信号量时,信号量的参数,初始值和个数的修改接口。

    // 构建权限
    virtual Bulider &Set_Perm(const int &perm)
    {
        perm_ = perm;
        return *this;
    }
    // 信号集中信号的个数
    virtual Bulider &Set_Num(const int &num)
    {
        num_ = num;
        return *this;
    }
    // 设置初始值
    virtual Bulider &Set_Value(const std::vector<int> &value)
    {
        value_ = value;
        return *this;
    }

最后在创建信号量后,还需要对信号量进行初始化,并且信号量集中可能不止一个信号量,所以此处采用构建两个接口的方式来初始化,一个用来对指定信号量进行初始化,另一个则用来初始化信号量集:

    // 初始化信号量
    virtual void Init_EachSem(int pos, int value)
    {
        union semun
        {
            int val;               /* Value for SETVAL */
            struct semid_ds *buf;  /* Buffer for IPC_STAT, IPC_SET */
            unsigned short *array; /* Array for GETALL, SETALL */
            struct seminfo *__buf; /* Buffer for IPC_INFO
                                      (Linux-specific) */
        };
        union semun arg;
        arg.val = value;
        int semid = sem_->Get_id();
        int n = semctl(semid, pos, SETVAL, arg);
        if (n < 0)
        {
            std::cerr << "semctl error" << " , int file" << __FILE__ << " , int line" << __LINE__ << std::endl;
            exit(3);
        }
    }

    // 初始化信号集
    virtual void Init_ALLSem()
    {
        // 一个个的进行初始化
        if (value_.size() != num_)    // 如果数量不够,默认全部是二元信号量
        {
            value_.resize(num_ , 1);
        }
        for (int i = 0; i < num_; i++)
        {
            Init_EachSem(i, value_[i]);
        }
    }

最后根据上面的接口,就可以实现信号量的创建了:

   // 构建函数,不进行初始化
    virtual void Create_Sem(int flag)
    {
        key_ = Build_Key();
        int _flag = flag;

        if(flag == SEM_CRAEY) flag |= perm_;

        // 创建信号集
        int semid = semget(key_, num_, flag);
        if (semid < 0)
        {
            std::cerr << "semget error" << " , int file : " << __FILE__ << " , int line : " << __LINE__ << std::endl;
            exit(2);
        }

        sem_ = std::make_shared<Semaphore>(semid , _flag);
    }
    // 获取信号量
    virtual std::shared_ptr<Semaphore> Get_Sem()
    {
        return sem_;
    }

指挥者

指挥者通过调用具体建造者的接口实现产品的构建,此处指挥者内部只需要判断时候需要初始化信号量:创建信号量时,需要初始化;获取时,不需要:

const int SEM_CRAEY = IPC_CREAT | IPC_EXCL;
const int SEM_GET = IPC_CREAT;

// 构建指挥者
class Director
{
public:
    void Construct(Bulider &bulider, int flag)
    {
        bulider.Create_Sem(flag);
        if (flag == SEM_CRAEY) // 要进行初始化
        {
            bulider.Init_ALLSem();
        }
    }

private:
};

以上就是所有类的实现,下面通过还是通过父子进程同时访问显示器资源为例,验证给类是否能够正常运行。

测试

测试代码与前面"信号量简单实现"中的测试代码类似,此处就不再赘述:

int main()
{   
    SemaphoreBulider bulider;
    bulider.Set_Num(3).Set_Perm(0600).Set_Value({1,2,3});
    std::shared_ptr<Director> dir = std::make_shared<Director>();
    dir->Construct(bulider , SEM_CRAEY);

    srand((unsigned)time(nullptr));

    std::shared_ptr<Semaphore> fsem = bulider.Get_Sem(); 

    std::cout << std::unitbuf;
    int pid = fork();
    if (pid == 0)
    {
        dir->Construct(bulider , SEM_GET);
        auto csem= bulider.Get_Sem(); 
        for (int i = 0; i < 10; i++)
        {
            csem->P(0);

            std::cout << "A";
            usleep(rand() % 100860);
            std::cout << "A";
            usleep(rand() % 1860);
            csem->V(0);
        }
        exit(0);
    }

    for (int i = 0; i < 10; i++)
    {
        fsem->P(0);

        std::cout << "B";
        usleep(rand() % 100860);
        std::cout << "B";
        usleep(rand() % 1860);
        fsem->V(0);
    }
    waitpid(pid , nullptr , 0);
    return 0;
}

运行结果:

请添加图片描述

可以看到父子进程在访问显示器资源的时候,确实是一次访问的。


网站公告

今日签到

点亮在社区的每一天
去签到