Linux进程间通信方式

进程与进程通信的概念

进程是操作系统的概念,每当我们执行一个程序时,对于操作系统来讲就创建了一个进程,在这个过程中,伴随着资源的分配和释放。可以认为进程是一个程序的一次执行过程。
进程用户空间是相互独立的,一般而言是不能相互访问的。但很多情况下进程间需要互相通信,来完成系统的某项功能。进程通过与内核及其它进程之间的互相通信来协调它们的行为。

进程通信的应用场景

  • 数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。

  • 共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。

  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

  • 资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。

  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程通信的几种方式

管道

管道简介

管道包括三种:
- 普通管道pipe: 通常有两种限制,一是单工,只能单向传输;二是只能在父子或者兄弟进程间使用.
- 流管道s_pipe: 去除了第一种限制,为半双工,只能在父子或兄弟进程间使用,可以双向传输.
- 命名管道:name_pipe:去除了第二种限制,可以在许多并不相关的进程之间进行通讯.

管道原理

管道如何通信

管道是由内核管理的一个缓冲区,相当于我们放入内存中的一个纸条。管道的一端连接一个进程的输出。这个进程会向管道中放入信息。管道的另一端连接一个进程的输入,这个进程取出被放入管道的信息。一个缓冲区不需要很大,它被设计成为环形的数据结构,以便管道可以被循环利用。当管道中没有信息的话,从管道中读取的进程会等待,直到另一端的进程放入信息。当管道被放满信息的时候,尝试放入信息的进程会等待,直到另一端的进程取出信息。当两个进程都终结的时候,管道也自动消失。
这里写图片描述

管道如何创建

从原理上,管道利用fork机制建立,从而让两个进程可以连接到同一个PIPE上。最开始的时候,上面的两个箭头都连接在同一个进程Process 1上(连接在Process 1上的两个箭头)。当fork复制进程的时候,会将这两个连接也复制到新的进程(Process 2)。随后,每个进程关闭自己不需要的一个连接 (两个黑色的箭头被关闭; Process 1关闭从PIPE来的输入连接,Process 2关闭输出到PIPE的连接),这样,剩下的红色连接就构成了如上图的PIPE。
这里写图片描述
那么管道创建后其对应的数据结构是什么呢?
在 Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统的file结构和VFS的索引节点inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。如下图:
管道数据结构
有两个 file 数据结构,但它们定义文件操作例程地址是不同的,其中一个是向管道中写入数据的例程地址,而另一个是从管道中读出数据的例程地址。这样,用户程序的系统调用仍然是通常的文件操作,而内核却利用这种抽象机制实现了管道这一特殊操作。

管道读写实现

管道实现的源代码在fs/pipe.c中,在pipe.c中有很多函数,其中有两个函数比较重要,即管道读函数pipe_read()和管道写函数pipe_wrtie()。管道写函数通过将字节复制到 VFS 索引节点指向的物理内存而写入数据,而管道读函数则通过复制物理内存中的字节而读出数据。当然,内核必须利用一定的机制同步对管道的访问,为此,内核使用了锁、等待队列和信号。

当写进程向管道中写入时,它利用标准的库函数write(),系统根据库函数传递的文件描述符,可找到该文件的 file 结构。file 结构中指定了用来进行写操作的函数(即写入函数)地址,于是,内核调用该函数完成写操作。写入函数在向内存中写入数据之前,必须首先检查 VFS 索引节点中的信息,同时满足如下条件时,才能进行实际的内存复制工作:

内存中有足够的空间可容纳所有要写入的数据;
内存没有被读程序锁定。
如果同时满足上述条件,写入函数首先锁定内存,然后从写进程的地址空间中复制数据到内存。否则,写入进程就休眠在 VFS 索引节点的等待队列中,接下来,内核将调用调度程序,而调度程序会选择其他进程运行。写入进程实际处于可中断的等待状态,当内存中有足够的空间可以容纳写入数据,或内存被解锁时,读取进程会唤醒写入进程,这时,写入进程将接收到信号。当数据写入内存之后,内存被解锁,而所有休眠在索引节点的读取进程会被唤醒。

管道的读取过程和写入过程类似。但是,进程可以在没有数据或内存被锁定时立即返回错误信息,而不是阻塞该进程,这依赖于文件或管道的打开模式。反之,进程可以休眠在索引节点的等待队列中等待写入进程写入数据。当所有的进程完成了管道操作之后,管道的索引节点被丢弃,而共享数据页也被释放。

管道api与用法

普通管道
#include <unistd.h>
int pipe(int filedes[2]);
filedes[0]用于读出数据,读取时必须关闭写入端,即close(filedes[1]);
filedes[1]用于写入数据,写入时必须关闭读取端,即close(filedes[0])。
int main(void)
{
    int n;
    int fd[2];
    pid_t pid;
    char line[MAXLINE];

    if(pipe(fd)!0){                 /* 先建立管道得到一对文件描述符 */
        exit(0);
    }

    if((pid = fork())!=0)            /* 父进程把文件描述符复制给子进程 */
        exit(1);
    else if(pid > 0){                /* 父进程写 */
        close(fd[0]);                /* 关闭读描述符 */
        write(fd[1], "\nhello world\n", 14);
    }
    else{                            /* 子进程读 */
        close(fd[1]);                /* 关闭写端 */
        n = read(fd[0], line, MAXLINE);
        write(STDOUT_FILENO, line, n);
    }

    exit(0);
}
流管道
命名管道

由于基于fork机制,所以管道只能用于父进程和子进程之间,或者拥有相同祖先的两个子进程之间 (有亲缘关系的进程之间)。为了解决这一问题,Linux提供了FIFO方式连接进程。FIFO又叫做命名管道(named PIPE)。

实现原理

FIFO (First in, First out)为一种特殊的文件类型,它在文件系统中有对应的路径。当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。之所以叫FIFO,是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。FIFO只是借用了文件系统(file system,命名管道是一种特殊类型的文件,因为Linux中所有事物都是文件,它在文件系统中以文件名的形式存在。)来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接。

api与应用
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
该函数的第一个参数是一个普通的路径名,也就是创建 后FIFO的名字。第二个参数与打开普通文件的open()函数中的mode 参数相同。 如果mkfifo的第一个参数是一个已经存在的路径名时,会返回EEXIST错误,所以一般典型的调用代码首先会检查是否返回该错误,如果确实返回该错 误,那么只要调用打开FIFO的函数就可以了。一般文件的I/O函数都可以用于FIFO,如close、read、write等等。
int mknode(const char *filename, mode_t mode | S_IFIFO, (dev_t) 0 );
其中filename是被创建的文件名称,mode表示将在该文件上设置的权限位和将被创建的文件类型(在此情况下为S_IFIFO),dev是当创建设备特殊文件时使用的一个值。因此,对于先进先出文件它的值为0

程序实例:

#include<sys/types.h>  
#include<sys/stat.h>  
#include<fcntl.h>  
char *FIFO = "/tmp/my_fifo";  
main()  
{  
char buffer[80];  
int fd;  
unlink(FIFO);  
mkfifo(FIFO,0666);  
if(fork()>0){  
char s[ ] = “hello!\n”;  
fd = open (FIFO,O_WRONLY);  
write(fd,s,sizeof(s));  
close(fd);  
}  
else{  
fd= open(FIFO,O_RDONLY);  
read(fd,buffer,80);  
printf(“%s”,buffer);  
close(fd);  
}  
}  

匿名管道和有名管道总结

(1)管道是特殊类型的文件,在满足先入先出的原则条件下可以进行读写,但不能进行定位读写。
(2)匿名管道是单向的,只能在有亲缘关系的进程间通信;有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
(3)无名管道阻塞问题:无名管道无需显示打开,创建时直接返回文件描述符,在读写时需要确定对方的存在,否则将退出。如果当前进程向无名管道的一端写数据,必须确定另一端有某一进程。如果写入无名管道的数据超过其最大值,写操作将阻塞,如果管道中没有数据,读操作将阻塞,如果管道发现另一端断开,将自动退出。
(4)有名管道阻塞问题:有名管道在打开时需要确实对方的存在,否则将阻塞。即以读方式打开某管道,在此之前必须一个进程以写方式打开管道,否则阻塞。此外,可以以读写(O_RDWR)模式打开有名管道,即当前进程读,当前进程写,不会阻塞。

信号

信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。

  • 信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,直到该进程恢复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时信号才被传递给进程。

    Linux系统中常用信号:
    (1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
    (2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
    (3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\\键将产生该信号。
    (4)SIGBUS和SIGSEGV:进程访问非法地址。
    (5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
    (6)SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
    (7)SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号。
    (8)SIGALRM:定时器信号。
    (9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。

信号来源

信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:
- 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。
- 软件来源:终止进程信号、其他进程调用kill函数、软件异常产生信号。

信号生命周期和处理流程

(1)信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;
(2)操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号。
(3)目的进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后再恢复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度。

api使用

消息队列

消息队列是Linux IPC中很常用的一种通信方式,它通常用来在不同进程间发送特定格式的消息数据。
消息队列和之前讨论过的管道和FIFO有很大的区别,主要有以下两点:
一个进程向消息队列写入消息之前,并不需要某个进程在该队列上等待该消息的到达,而管道和FIFO是相反的,进程向其中写消息时,管道和FIFO必需已经打开来读,那么内核会产生SIGPIPE信号。
IPC的持续性不同。管道和FIFO是随进程的持续性,当管道和FIFO最后一次关闭发生时,仍在管道和FIFO中的数据会被丢弃。消息队列是随内核的持续性,即一个进程向消息队列写入消息后,然后终止,另外一个进程可以在以后某个时刻打开该队列读取消息。只要内核没有重新自举,消息队列没有被删除。
消息队列中的每条消息通常具有以下属性:
- 一个表示优先级的整数;
- 消息的数据部分的长度;
- 消息数据本身

实现原理

api与应用(以Posix为例)

  • POSIX消息队列的创建和关闭
    POSIX消息队列的创建,关闭和删除用到以下三个函数接口:
#include <mqueue.h>  
mqd_t mq_open(const char *name, int oflag, /* mode_t mode, struct mq_attr *attr */);  
                       //成功返回消息队列描述符,失败返回-1  
mqd_t mq_close(mqd_t mqdes);  
mqd_t mq_unlink(const char *name);  
                           //成功返回0,失败返回-1  
mq_open用于打开或创建一个消息队列。
name:表示消息队列的名字,它符合POSIX IPC的名字规则。
oflag:表示打开的方式,和open函数的类似。有必须的选项:O_RDONLY,O_WRONLY,O_RDWR,还有可选的选项:O_NONBLOCK,O_CREAT,O_EXCL。
mode:是一个可选参数,在oflag中含有O_CREAT标志且消息队列不存在时,才需要提供该参数。表示默认访问权限。可以参考open。
attr:也是一个可选参数,在oflag中含有O_CREAT标志且消息队列不存在时才需要。该参数用于给新队列设定某些属性,如果是空指针,那么就采用默认属性。
mq_open返回值是mqd_t类型的值,被称为消息队列描述符。在Linux 2.6.18中该类型的定义为整型:
#include <bits/mqueue.h>  
typedef int mqd_t;  
mq_close用于关闭一个消息队列,和文件的close类型,关闭后,消息队列并不从系统中删除。一个进程结束,会自动调用关闭打开着的消息队列。
mq_unlink用于删除一个消息队列。消息队列创建后只有通过调用该函数或者是内核自举才能进行删除。每个消息队列都有一个保存当前打开着描述符数的引用计数器,和文件一样,因此本函数能够实现类似于unlink函数删除一个文件的机制。
  • POSIX消息队列的属性
    POSIX标准规定消息队列属性mq_attr必须要含有以下四个内容:
long    mq_flags //消息队列的标志:0或O_NONBLOCK,用来表示是否阻塞   
long    mq_maxmsg  //消息队列的最大消息数  
long    mq_msgsize  //消息队列中每个消息的最大字节数  
long    mq_curmsgs  //消息队列中当前的消息数目  
在Linux 2.6.18中mq_attr结构的定义如下:
#include <bits/mqueue.h>  
struct mq_attr  
{  
  long int mq_flags;      /* Message queue flags.  */  
  long int mq_maxmsg;   /* Maximum number of messages.  */  
  long int mq_msgsize;   /* Maximum message size.  */  
  long int mq_curmsgs;   /* Number of messages currently queued.  */  
  long int __pad[4];  
};  
POSIX消息队列的属性设置和获取可以通过下面两个函数实现:
#include <mqueue.h>  
mqd_t mq_getattr(mqd_t mqdes, struct mq_attr *attr);  
mqd_t mq_setattr(mqd_t mqdes, struct mq_attr *newattr, struct mq_attr *oldattr);  
                               //成功返回0,失败返回-1  
mq_getattr用于获取当前消息队列的属性,mq_setattr用于设置当前消息队列的属性。其中mq_setattr中的oldattr用于保存修改前的消息队列的属性,可以为空。
mq_setattr可以设置的属性只有mq_flags,用来设置或清除消息队列的非阻塞标志。newattr结构的其他属性被忽略。mq_maxmsg和mq_msgsize属性只能在创建消息队列时通过mq_open来设置。mq_open只会设置该两个属性,忽略另外两个属性。mq_curmsgs属性只能被获取而不能被设置。

下面是测试代码:

#include <iostream>  
#include <cstring>  

#include <errno.h>  
#include <unistd.h>  
#include <fcntl.h>  
#include <mqueue.h>  

using namespace std;  

int main()  
{  
    mqd_t mqID;  
    mqID = mq_open("/anonymQueue", O_RDWR | O_CREAT, 0666, NULL);  

    if (mqID < 0)  
    {  
        cout<<"open message queue error..."<<strerror(errno)<<endl;  
        return -1;  
    }  

    mq_attr mqAttr;  
    if (mq_getattr(mqID, &mqAttr) < 0)  
    {  
        cout<<"get the message queue attribute error"<<endl;  
        return -1;  
    }  

    cout<<"mq_flags:"<<mqAttr.mq_flags<<endl;  
    cout<<"mq_maxmsg:"<<mqAttr.mq_maxmsg<<endl;  
    cout<<"mq_msgsize:"<<mqAttr.mq_msgsize<<endl;  
    cout<<"mq_curmsgs:"<<mqAttr.mq_curmsgs<<endl;  
}  
在Linux 2.6.18中执行结果是:
mq_flags:0  
mq_maxmsg:10  
mq_msgsize:8192  
mq_curmsgs:0  
  • POSIX消息队列的使用

POSIX消息队列可以通过以下两个函数来进行发送和接收消息:

#include <mqueue.h>  
mqd_t mq_send(mqd_t mqdes, const char *msg_ptr,  
                      size_t msg_len, unsigned msg_prio);  
                     //成功返回0,出错返回-1  

mqd_t mq_receive(mqd_t mqdes, char *msg_ptr,  
                      size_t msg_len, unsigned *msg_prio);  
                     //成功返回接收到消息的字节数,出错返回-1  

#ifdef __USE_XOPEN2K  
mqd_t mq_timedsend(mqd_t mqdes, const char *msg_ptr,  
                      size_t msg_len, unsigned msg_prio,  
                      const struct timespec *abs_timeout);  

mqd_t mq_timedreceive(mqd_t mqdes, char *msg_ptr,  
                      size_t msg_len, unsigned *msg_prio,  
                      const struct timespec *abs_timeout);  
#endif  
mq_send向消息队列中写入一条消息,mq_receive从消息队列中读取一条消息。
mqdes:消息队列描述符;
msg_ptr:指向消息体缓冲区的指针;
msg_len:消息体的长度,其中mq_receive的该参数不能小于能写入队列中消息的最大大小,即一定要大于等于该队列的mq_attr结构中mq_msgsize的大小。如果mq_receive中的msg_len小于该值,就会返回EMSGSIZE错误。POXIS消息队列发送的消息长度可以为0。
msg_prio:消息的优先级;它是一个小于MQ_PRIO_MAX的数,数值越大,优先级越高。POSIX消息队列在调用mq_receive时总是返回队列中最高优先级的最早消息。如果消息不需要设定优先级,那么可以在mq_send是置msg_prio为0,mq_receive的msg_prio置为NULL。
还有两个XSI定义的扩展接口限时发送和接收消息的函数:mq_timedsend和mq_timedreceive函数。默认情况下mq_send和mq_receive是阻塞进行调用,可以通过mq_setattr来设置为O_NONBLOCK。

下面是消息队列使用的测试代码:

#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <mqueue.h>  

using namespace std;  

int main()  
{  
    mqd_t mqID;  
    mqID = mq_open("/anonymQueue", O_RDWR | O_CREAT | O_EXCL, 0666, NULL);  

    if (mqID < 0)  
    {  
        if (errno == EEXIST)  
        {  
            mq_unlink("/anonymQueue");  
            mqID = mq_open("/anonymQueue", O_RDWR | O_CREAT, 0666, NULL);  
        }  
        else  
        {  
            cout<<"open message queue error..."<<strerror(errno)<<endl;  
            return -1;  
        }  
    }  

    if (fork() == 0)  
    {  
        mq_attr mqAttr;  
        mq_getattr(mqID, &mqAttr);  

        char *buf = new char[mqAttr.mq_msgsize];  

        for (int i = 1; i <= 5; ++i)  
        {  
            if (mq_receive(mqID, buf, mqAttr.mq_msgsize, NULL) < 0)  
            {  
                cout<<"receive message  failed. ";  
                cout<<"error info:"<<strerror(errno)<<endl;  
                continue;  
            }  

            cout<<"receive message "<<i<<": "<<buf<<endl;     
        }  
        exit(0);  
    }  

    char msg[] = "yuki";  
    for (int i = 1; i <= 5; ++i)  
    {  
        if (mq_send(mqID, msg, sizeof(msg), i) < 0)  
        {  
            cout<<"send message "<<i<<" failed. ";  
            cout<<"error info:"<<strerror(errno)<<endl;  
        }  
        cout<<"send message "<<i<<" success. "<<endl;     

        sleep(1);  
    }  
}                       
在Linux 2.6.18下的执行结构如下:
send message 1 success.   
receive message 1: yuki  
send message 2 success.   
receive message 2: yuki  
send message 3 success.   
receive message 3: yuki  
send message 4 success.   
receive message 4: yuki  
send message 5 success.   
receive message 5: yuki  
  • POSIX消息队列的限制
    POSIX消息队列本身的限制就是mq_attr中的mq_maxmsg和mq_msgsize,分别用于限定消息队列中的最大消息数和每个消息的最大字节数。在前面已经说过了,这两个参数可以在调用mq_open创建一个消息队列的时候设定。当这个设定是受到系统内核限制的。
    下面是在Linux 2.6.18下shell对启动进程的POSIX消息队列大小的限制:
# ulimit -a |grep message  
POSIX message queues     (bytes, -q) 819200 

限制大小为800KB,该大小是整个消息队列的大小,不仅仅是最大消息数*消息的最大大小;还包括消息队列的额外开销。前面我们知道Linux 2.6.18下POSIX消息队列默认的最大消息数和消息的最大大小分别为:

mq_maxmsg = 10  
mq_msgsize = 8192

为了说明上面的限制大小包括消息队列的额外开销,下面是测试代码:

#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <mqueue.h>  

using namespace std;  

int main(int argc, char **argv)  
{  
    mqd_t mqID;  
    mq_attr attr;  
    attr.mq_maxmsg = atoi(argv[1]);  
    attr.mq_msgsize = atoi(argv[2]);  

    mqID = mq_open("/anonymQueue", O_RDWR | O_CREAT | O_EXCL, 0666, &attr);  

    if (mqID < 0)  
    {  
        if (errno == EEXIST)  
        {  
            mq_unlink("/anonymQueue");  
            mqID = mq_open("/anonymQueue", O_RDWR | O_CREAT, 0666, &attr);  

            if(mqID < 0)  
            {  
                cout<<"open message queue error..."<<strerror(errno)<<endl;  
                return -1;  
            }  
        }  
        else  
        {  
            cout<<"open message queue error..."<<strerror(errno)<<endl;  
            return -1;  
        }  
    }  

    mq_attr mqAttr;  
    if (mq_getattr(mqID, &mqAttr) < 0)  
    {  
        cout<<"get the message queue attribute error"<<endl;  
        return -1;  
    }  

    cout<<"mq_flags:"<<mqAttr.mq_flags<<endl;  
    cout<<"mq_maxmsg:"<<mqAttr.mq_maxmsg<<endl;  
    cout<<"mq_msgsize:"<<mqAttr.mq_msgsize<<endl;  
    cout<<"mq_curmsgs:"<<mqAttr.mq_curmsgs<<endl;   
}  

下面进行创建消息队列时设置最大消息数和消息的最大大小进行测试:

[root@idcserver program]# g++ -g test.cpp -lrt  
[root@idcserver program]# ./a.out 10 81920  
open message queue error...Cannot allocate memory  
[root@idcserver program]# ./a.out 10 80000  
open message queue error...Cannot allocate memory  
[root@idcserver program]# ./a.out 10 70000  
open message queue error...Cannot allocate memory  
[root@idcserver program]# ./a.out 10 60000  
mq_flags:0  
mq_maxmsg:10  
mq_msgsize:60000  
mq_curmsgs:0  

从上面可以看出消息队列真正存放消息数据的大小是没有819200B的。可以通过修改该限制参数,来改变消息队列的所能容纳消息的数量。可以通过下面方式来修改限制,但这会在shell启动进程结束后失效,可以将设置写入开机启动的脚本中执行,例如.bashrc,rc.local。

[root@idcserver ~]# ulimit -q 1024000000  
[root@idcserver ~]# ulimit -a |grep message  
POSIX message queues     (bytes, -q) 1024000000  

下面再次测试可以设置的消息队列的属性。

[root@idcserver program]# ./a.out 10 81920  
mq_flags:0  
mq_maxmsg:10  
mq_msgsize:81920  
mq_curmsgs:0  
[root@idcserver program]# ./a.out 10 819200  
mq_flags:0  
mq_maxmsg:10  
mq_msgsize:819200  
mq_curmsgs:0  
[root@idcserver program]# ./a.out 1000 8192    
mq_flags:0  
mq_maxmsg:1000  
mq_msgsize:8192  
mq_curmsgs:0  

POSIX消息队列在实现上还有另外两个限制:
MQ_OPEN_MAX:一个进程能同时打开的消息队列的最大数目,POSIX要求至少为8;
MQ_PRIO_MAX:消息的最大优先级,POSIX要求至少为32;

共享内存

实现原理

管道,FIFO,消息队列,他们的共同特点就是通过内核来进行通信(假设POSIX消息队列也是在内核中实现的,因为POSIX标准并没有限定它的实现方式)。向管道,FIFO,消息队列写入数据需要把数据从进程复制到内核,从这些IPC读取数据的时候又需要把数据从内核复制到进程。所以这种IPC方式往往需要2次在进程和内核之间进行数据的复制,即进程间的通信必须借助内核来传递。如下图所示:
内核IPC通信
共享内存也是一种IPC,它是目前可用IPC中最快的,它是使用方式是将同一个内存区映射到共享它的不同进程的地址空间中,这样这些进程间的通信就不再需要通过内核,只需对该共享的内存区域进程操作就可以了,和其他IPC不同的是,共享内存的使用需要用户自己进行同步操作。下图是共享内存区IPC的通信:
共享内存IPC通信

api和应用

Linux下有三种共享内存的IPC技术:System V共享内存、共享文件映射(mmap)、POSIX共享内存。

system V共享内存
共享文件映射

mmap函数主要的功能就是将文件或设备映射到调用进程的地址空间中,当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址进行文件的读写等操作,不必再调用read,write等系统调用。在很大程度上提高了系统的效率和代码的简洁性。
使用mmap函数的主要目的是:
- 对普通文件提供内存映射I/O,可以提供无亲缘进程间的通信;
- 提供匿名内存映射,以供亲缘进程间进行通信。
- 对shm_open创建的POSIX共享内存区对象进程内存映射,以供无亲缘进程间进行通信。
下面是mmap函数的接口以及说明:

#include <sys/mman.h>  
void *mmap(void *start, size_t len, int prot, int flags, int fd, off_t offset);  
               //成功返回映射到进程地址空间的起始地址,失败返回MAP_FAILED  
start:指定描述符fd应被映射到的进程地址空间内的起始地址,它通常被设置为空指针NULL,这告诉内核自动选择起始地址,该函数的返回值即为fd映射到内存区的起始地址。
len:映射到进程地址空间的字节数,它从被映射文件开头的第offset个字节处开始,offset通常被设置为0。

prot:内存映射区的保护由该参数来设定,通常由以下几个值组合而成:
PROT_READ:数据可读;
 PROT_WRITE:数据可写;
 PROT_EXEC:数据可执行;
 PROT_NONE:数据不可访问;
flags:设置内存映射区的类型标志,POSIX标志定义了以下三个标志:
MAP_SHARED:该标志表示,调用进程对被映射内存区的数据所做的修改对于共享该内存区的所有进程都可见,而且确实改变其底层的支撑对象(一个文件对象或是一个共享内存区对象)。
 MAP_PRIVATE:调用进程对被映射内存区的数据所做的修改只对该进程可见,而不改变其底层支撑对象。
 MAP_FIXED:该标志表示准确的解释start参数,一般不建议使用该标志,对于可移植的代码,应该把start参数置为NULL,且不指定MAP_FIXED标志。
上面三个标志是在POSIX.1-2001标准中定义的,其中MAP_SHARED和MAP_PRIVATE必须选择一个。在Linux中也定义了一些非标准的标志,例如MAP_ANONYMOUS(MAP_ANON),MAP_LOCKED等,具体参考Linux手册。
fd:有效的文件描述符。如果设定了MAP_ANONYMOUS(MAP_ANON)标志,在Linux下面会忽略fd参数,而有的系统实现如BSD需要置fd为-1offset:相对文件的起始偏移。

mmap成功后,可以关闭fd,一般也是这么做的,这对该内存映射没有任何影响。
从进程的地址空间中删除一个映射关系,需要用到下面的函数:

#include <sys/mman.h>  
int munmap(void *start, size_t len);  
                           //成功返回0,出错返回-1  
start:被映射到的进程地址空间的内存区的起始地址,即mmap返回的地址。
len:映射区的大小。

对于一个MAP_SHARED的内存映射区,内核的虚拟内存算法会保持内存映射文件和内存映射区的同步,也就是说,对于内存映射文件所对应内存映射区的修改,内核会在稍后的某个时刻更新该内存映射文件。如果我们希望硬盘上的文件内容和内存映射区中的内容实时一致,那么我们就可以调用msync开执行这种同步:

#include <sys/mman.h>  
int msync(void *start, size_t len, int flags);  //成功返回0,出错返回-1  
start:被映射到的进程地址空间的内存区的起始地址,即mmap返回的地址。
len:映射区的大小。
flags:同步标志,有一下三个标志:
MS_ASYNC:异步写,一旦写操作由内核排入队列,就立刻返回;
MS_SYNC:同步写,要等到写操作完成后才返回。
MS_INVALIDATE:使该文件的其他内存映射的副本全部失效。
  • mmap内存映射区的大小
    Linux下的内存是采用页式管理机制。通过mmap进行内存映射,内核生成的映射区的大小都是以页面大小PAGESIZE为单位,即为PAGESIZE的整数倍。如果mmap映射的长度不是页面大小的整数倍,那么多余空间也会被闲置浪费。
  • mmap实现进程间通信
    mmap本身提供的进程间通信的两种方式,分别用于无亲缘和亲缘进程间的通信。
    (1)通过匿名内存映射提供亲缘进程间的通信
    我们可以通过在父进程fork之前指定MAP_SHARED调用mmap,通过映射一个文件来实现父子进程间的通信,POSIX保证了父进程的内存映射关系保留到子进程中,父子进程对内存映射区的修改双方都可以看到。
    在Linux 2.4以后,mmap提供匿名内存映射机制,即将mmap的flags参数指定为:MAP_SHARED | MAP_ANON。这样就彻底避免了内存映射文件的创建和打开,简化了对文件的操作。匿名内存映射机制的目的就是为了提供一个穿越父子进程间的内存映射区,很方便的提供了亲缘进程间的通信,下面是测试代码:
#include <iostream>  
#include <cstring>  
#include <cerrno>  

#include <unistd.h>  
#include <fcntl.h>  
#include <sys/mman.h>  

using namespace std;  

int main(int argc, char **argv)  
{  
    int *memPtr;  

    memPtr = (int *) mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANON, 0, 0);  
    if (memPtr == MAP_FAILED)  
    {  
        cout<<"mmap failed..."<<strerror(errno)<<endl;  
        return -1;  
    }  

    *memPtr = 0;  

    if (fork() == 0)  
    {  
        *memPtr = 1;  
        cout<<"child:set memory "<<*memPtr<<endl;  

        exit(0);  
    }  

    sleep(1);  
    cout<<"parent:memory value "<<*memPtr<<endl;  

    return 0;  
}  
执行结果如下:
child:set memory 1  
parent:memory value 1  

(2)通过内存映射文件提供无亲缘进程间的通信
通过在不同进程间对同一内存映射文件进行映射,来进行无亲缘进程间的通信,如下测试代码:

//process 1  
#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <sys/mman.h>  

using namespace std;  

#define  PATH_NAME "/tmp/memmap"  

int main()  
{  
    int *memPtr;  
    int fd;  

    fd = open(PATH_NAME, O_RDWR | O_CREAT, 0666);  
    if (fd < 0)  
    {  
        cout<<"open file "<<PATH_NAME<<" failed...";  
        cout<<strerror(errno)<<endl;  
        return -1;  
    }  

    ftruncate(fd, sizeof(int));  

    memPtr = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  
    close(fd);  

    if (memPtr == MAP_FAILED)  
    {  
        cout<<"mmap failed..."<<strerror(errno)<<endl;  
        return -1;  
    }  

    *memPtr = 111;  
    cout<<"process:"<<getpid()<<" send:"<<*memPtr<<endl;  

    return 0;  
}  

//process 2  
#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <sys/mman.h>  

using namespace std;  

#define  PATH_NAME "/tmp/memmap"  

int main()  
{  
    int *memPtr;  
    int fd;  

    fd = open(PATH_NAME, O_RDWR | O_CREAT, 0666);  
    if (fd < 0)  
    {  
        cout<<"open file "<<PATH_NAME<<" failed...";  
        cout<<strerror(errno)<<endl;  
        return -1;  
    }  

    memPtr = (int *)mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  
    close(fd);  

    if (memPtr == MAP_FAILED)  
    {  
        cout<<"mmap failed..."<<strerror(errno)<<endl;  
        return -1;  
    }  

    cout<<"process:"<<getpid()<<" receive:"<<*memPtr<<endl;  

    return 0;  
}  
执行结果如下:
# ./send   
process:12711 send:111  
# ./recv   
process:12712 receive:111  

上面的代码都没进行同步操作,在实际的使用过程要考虑到进程间的同步,通常会用信号量来进行共享内存的同步。

posix共享内存

上面介绍了通过内存映射文件进行进程间的通信的方式,现在要介绍的是通过POSIX共享内存区对象进行进程间的通信。POSIX共享内存使用方法有以下两个步骤:
1)通过shm_open创建或打开一个POSIX共享内存对象;
2)然后调用mmap将它映射到当前进程的地址空间;
和通过内存映射文件进行通信的使用上差别在于mmap描述符参数获取方式不一样,前者通过open而后者通过shm_open。
POSIX共享内存区对象的特殊操作函数就只有创建(打开)和删除两个函数,其他对共享内存区对象的操作都是通过已有的函数进行的。

#include <sys/mman.h>  
int shm_open(const char *name, int oflag, mode_t mode);  
                              //成功返回非负的描述符,失败返回-1  
int shm_unlink(const char *name);  
                              //成功返回0,失败返回-1  
shm_open用于创建一个新的共享内存区对象或打开一个已经存在的共享内存区对象。
name:POSIX IPC的名字,前面关于POSIX进程间通信都已讲过关于POSIX IPC的规则,这里不再赘述。
oflag:操作标志,包含:O_RDONLY,O_RDWR,O_CREAT,O_EXCL,O_TRUNC。其中O_RDONLY和O_RDWR标志必须且仅能存在一项。
mode:用于设置创建的共享内存区对象的权限属性。和open以及其他POSIX IPC的xxx_open函数不同的是,该参数必须一直存在,如果oflag参数中没有O_CREAT标志,该位可以置0;
shm_unlink用于删除一个共享内存区对象,跟其他文件的unlink以及其他POSIX IPC的删除操作一样,对象的析构会到对该对象的所有引用全部关闭才会发生。

POSIX共享内存和POSIX消息队列,有名信号量一样都是具有随内核持续性[^1]的特点。
下面是通过POSIX共享内存进行通信的测试代码,代码中通过POSIX信号量来进行进程间的同步操作。

//process 1  
#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <semaphore.h>  
#include <sys/mman.h>  

using namespace std;  

#define SHM_NAME "/memmap"  
#define SHM_NAME_SEM "/memmap_sem"   

char sharedMem[10];  

int main()  
{  
    int fd;  
    sem_t *sem;  

    fd = shm_open(SHM_NAME, O_RDWR | O_CREAT, 0666);  
    sem = sem_open(SHM_NAME_SEM, O_CREAT, 0666, 0);  

    if (fd < 0 || sem == SEM_FAILED)  
    {  
        cout<<"shm_open or sem_open failed...";  
        cout<<strerror(errno)<<endl;  
        return -1;  
    }  

    ftruncate(fd, sizeof(sharedMem));  

    char *memPtr;  
    memPtr = (char *)mmap(NULL, sizeof(sharedMem), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  
    close(fd);  

    char msg[] = "yuki...";  

    memmove(memPtr, msg, sizeof(msg));  
    cout<<"process:"<<getpid()<<" send:"<<memPtr<<endl;  

    sem_post(sem);  
    sem_close(sem);  

    return 0;  
}  

//process 2  
#include <iostream>  
#include <cstring>  
#include <errno.h>  

#include <unistd.h>  
#include <fcntl.h>  
#include <semaphore.h>  
#include <sys/mman.h>  

using namespace std;  

#define SHM_NAME "/memmap"  
#define SHM_NAME_SEM "/memmap_sem"   

int main()  
{  
    int fd;  
    sem_t *sem;  

    fd = shm_open(SHM_NAME, O_RDWR, 0);  
    sem = sem_open(SHM_NAME_SEM, 0);  

    if (fd < 0 || sem == SEM_FAILED)  
    {  
        cout<<"shm_open or sem_open failed...";  
        cout<<strerror(errno)<<endl;  
        return -1;  
    }  

    struct stat fileStat;  
    fstat(fd, &fileStat);  

    char *memPtr;  
    memPtr = (char *)mmap(NULL, fileStat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  
    close(fd);  

    sem_wait(sem);  

    cout<<"process:"<<getpid()<<" recv:"<<memPtr<<endl;  

    sem_close(sem);  

    return 0;  
}  
程序的执行结果如下:
# ./send   
process:13719 send:yuki...  
# ./recv   
process:13720 recv:yuki...  

在Linux 2.6.18中,对于POSIX信号量和共享内存的名字会在/dev/shm下建立对应的路径名,例如上面的测试代码,会生成如下的路径名:

# ll /dev/shm/  
total 8  
-rw-r--r-- 1 root root 10 Aug 13 00:28 memmap  
-rw-r--r-- 1 root root 32 Aug 13 00:28 sem.memmap_sem  

信号量

原始套接字

套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。
这里写图片描述
socket是应用层和传输层之间的桥梁

套接字特性

套接字的特性由3个属性确定,它们分别是:域、端口号、协议类型。
(1)套接字的域
它指定套接字通信中使用的网络介质,最常见的套接字域有两种:
一是AF_INET,它指的是Internet网络。当客户使用套接字进行跨网络的连接时,它就需要用到服务器计算机的IP地址和端口来指定一台联网机器上的某个特定服务,所以在使用socket作为通信的终点,服务器应用程序必须在开始通信之前绑定一个端口,服务器在指定的端口等待客户的连接。
另一个域AF_UNIX(AF_LOCAL),表示UNIX文件系统,它就是文件输入/输出,而它的地址就是文件名。
(2)套接字的端口号
每一个基于TCP/IP网络通讯的程序(进程)都被赋予了唯一的端口和端口号,端口是一个信息缓冲区,用于保留Socket中的输入/输出信息,端口号是一个16位无符号整数,范围是0-65535,以区别主机上的每一个程序(端口号就像房屋中的房间号),低于256的端口号保留给标准应用程序,比如pop3的端口号就是110,每一个套接字都组合进了IP地址、端口,这样形成的整体就可以区别每一个套接字。
(3)套接字协议类型
因特网提供三种通信机制,

  1. 流套接字(SOCK_STREAM)
    流套接字在域中通过TCP/IP连接实现,同时也是AF_UNIX中常用的套接字类型。流套接字提供的是一个有序、可靠、双向字节流的连接,因此发送的数据可以确保不会丢失、重复或乱序到达,而且它还有一定的出错后重新发送的机制。
  2. 数据报套接字(SOCK_DGRAM)
    它不需要建立连接和维持一个连接,它们在域中通常是通过UDP/IP协议实现的。它对可以发送的数据的长度有限制,数据报作为一个单独的网络消息被传输,它可能会丢失、复制或错乱到达,UDP不是一个可靠的协议,但是它的速度比较高,因为它并一需要总是要建立和维持一个连接。
  3. 原始套接字(SOCK_RAW)
    原始套接字允许对较低层次的协议直接访问,比如IP、 ICMP协议,它常用于检验新的协议实现,或者访问现有服务中配置的新设备,因为RAW SOCKET可以自如地控制Windows下的多种协议,能够对网络底层的传输机制进行控制,所以可以应用原始套接字来操纵网络层和传输层应用。比如,我们可以通过RAW SOCKET来接收发向本机的ICMP、IGMP协议包,或者接收TCP/IP栈不能够处理的IP包,也可以用来发送一些自定包头或自定协议的IP包。网络监听技术很大程度上依赖于SOCKET_RAW。
    原始套接字与标准套接字的区别在于:
    原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送数据必须使用原始套接字。

api实例

服务端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define UNIX_DOMAIN "/tmp/UNIX.domain"

int main(void)
{
  int lsn_fd, apt_fd;
  struct sockaddr_un srv_addr;
  struct sockaddr_un clt_addr;
  socklen_t clt_len;
  int ret;
  int i;
  char recv_buf[1024];
  char send_buf[1024];

  //create socket to bind local IP and PORT
  lsn_fd = socket(PF_UNIX, SOCK_STREAM, 0);
  if(lsn_fd < 0)
  {
    perror("can't create communication socket!");
    return 1;
  }

  //create local IP and PORT
  srv_addr.sun_family = AF_UNIX;
  strncpy(srv_addr.sun_path, UNIX_DOMAIN, sizeof(srv_addr.sun_path) - 1);
  unlink(UNIX_DOMAIN);

  //bind sockfd and sockaddr
  ret = bind(lsn_fd, (struct sockaddr*)&srv_addr, sizeof(srv_addr));
  if(ret == -1)
  {
    perror("can't bind local sockaddr!");
    close(lsn_fd);
    unlink(UNIX_DOMAIN);
    return 1;
  }

  //listen lsn_fd, try listen 1
  ret = listen(lsn_fd, 1);
  if(ret == -1)
  {
    perror("can't listen client connect request");
    close(lsn_fd);
    unlink(UNIX_DOMAIN);
    return 1;
  }

  clt_len = sizeof(clt_addr);
  while(1)
  {
    apt_fd = accept(lsn_fd, (struct sockaddr*)&clt_addr, &clt_len);
    if(apt_fd < 0)
    {
      perror("can't listen client connect request");
      close(lsn_fd);
      unlink(UNIX_DOMAIN);
      return 1;
    }

    printf("received a connection\n");
    printf("send message to client\n");
    memset(send_buf, 0, 1024);
    strcpy(send_buf, "Hello, you have connected to server succeed");

    int snd_num = write(apt_fd, send_buf, 1024);
    if(snd_num != 1024)
    {
      perror("send messge to client failed\n");
      close(apt_fd);
      close(lsn_fd);
      unlink(UNIX_DOMAIN);
      return 1;
    }
    //read and printf client info
    printf("============info=================\n");
    for(i = 0; i < 4; i++)
    {
      memset(recv_buf, 0, 1024);
      int rcv_num = read(apt_fd, recv_buf, sizeof(recv_buf));
      printf("Message from client (%d) :%s\n", rcv_num, recv_buf);
    }
  }
  close(apt_fd);
  close(lsn_fd);
  unlink(UNIX_DOMAIN);
  return 0;
}

客户端

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define UNIX_DOMAIN "/tmp/UNIX.domain"

int main(void)
{
  int connect_fd;
  struct sockaddr_un srv_addr;
  char snd_buf[1024];
  char rcv_buf[1024];
  int ret;
  int i;

  //create client socket
  connect_fd = socket(PF_UNIX, SOCK_STREAM, 0);
  if(connect_fd < 0)
  {
    perror("client create socket failed");
    return 1;
  }

  //set server sockaddr_un
  srv_addr.sun_family = AF_UNIX;
  strcpy(srv_addr.sun_path, UNIX_DOMAIN);

  //connect to server
  ret = connect(connect_fd, (struct sockaddr*)&srv_addr, sizeof(srv_addr));
  if(ret == -1)
  {
    perror("connect to server failed!");
    close(connect_fd);
    unlink(UNIX_DOMAIN);
    return 1;
  }

  //receive message from server
  memset(rcv_buf, 0, 1024);
  int rcv_num = read(connect_fd, rcv_buf, sizeof(rcv_buf));
  printf("receive message from server (%d) :%s\n", rcv_num, rcv_buf);
  //printf("\n");

  //send message to server
  memset(snd_buf, 0, 1024);
  strcpy(snd_buf, "message from client");
  printf("sizeof(snd_buf): %d\n", sizeof(snd_buf));
  sleep(2000);

  //send message to server
  for(i = 0; i < 4; i++)
  {
    write(connect_fd, snd_buf, sizeof(snd_buf));
  }
  close(connect_fd);
  return 0;
}
进程通信方式原理特点
管道管道及有名管道及有名管道则是典型的随进程持续IPC[^1],并且只能传送无格式的字节流无疑会给应用程序开发带来不便,另外,它的缓冲区大小也受到限制。不需要额外同步机制
信号待补充
消息队列消息队列就是一个消息的链表。可以把消息看作一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向中按照一定的规则添加新消息;对消息队列有读权限的进程则可以从消息队列中读走消息。消息队列是随内核持续的不需要额外同步机制,消息队列与管道以及有名管道相比,具有更大的灵活性,首先,它提供有格式字节流,有利于减少开发人员的工作量;其次,消息具有类型,在实际应用中,可作为优先级使用。这两点是管道以及有名管道所不能比的。同样,消息队列可以在几个进程间复用,而不管这几个进程是否具有亲缘关系,这一点与有名管道很相似;但消息队列是随内核持续的,与有名管道(随进程持续)相比,生命力更强,应用空间更大
共享内存待补充需要额外同步机制
信号量待补充
原始套接字待补充

[^1]
随进程持续:IPC一直存在到打开IPC对象的最后一个进程关闭该对象为止。如管道和有名管道;
随内核持续:IPC一直持续到内核重新自举或者显示删除该对象为止。如消息队列、信号灯以及共享内存等;
随文件系统持续:IPC一直持续到显示删除该对象为止。

  • 21
    点赞
  • 136
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值