《UNIX网络编程 卷2:进程间通信》笔记

第一部分 简介

IPC是进程间通信(interprocess communication)的简称。

1.IPC三种类型的持续性
这里写图片描述
随进程持续: IPC对象一直存在到打开着该对象的最后一个进程关闭该对象为止。例如管道和FIFO。
随内核持续: IPC对象一直存在到内核重新自举或显式删除该对象为止。例如消息队里、信号量和共享内存。
随文件系统持续: IPC对象一直存在到显式删除该对象为止。例如使用映射文件实现的Posix消息队里、信号量和共享内存。

2.Posix IPC 和 System V IPC

两种标准的进程通信

SYSTEM V进程通信。包含消息队列、共享内存和信号量。
POSIX 进程通信。新标准的进程通信。

这里写图片描述

这里写图片描述

系统为每一个IPC对象保存一个ipc_perm结构体,该结构说明了IPC对象的权限和所有者,每一个版本的内核各有不用的ipc_perm结构成员。若要查看详细的定义请参阅文件 <sys/ipc.h>。

ipc_perm 结构定义于中,原型如下:
struct ipc_perm
{
key_t        key;                        调用shmget()时给出的关键字
uid_t           uid;                      /*共享内存所有者的有效用户ID */
gid_t          gid;                       /* 共享内存所有者所属组的有效组ID*/ 
uid_t          cuid;                    /* 共享内存创建 者的有效用户ID*/
gid_t         cgid;                   /* 共享内存创建者所属组的有效组ID*/
unsigned short   mode;    /* Permissions + SHM_DEST和SHM_LOCKED标志*/
unsignedshort    seq;          /* 序列号*/
};

3.ipcs 和 ipcrm程序

ipcs输出有关System V IPC特性的各种信息,ipcrm 则删除一个System V消息队列、信号量集或共享内存。

第二部分 消息传递

1.管道和FIFO

  • 管道

单向数据流。

#include <unistd.h>
int pipe(int fd[2]);

成功返回0,出错为-1

这里写图片描述

特殊情况:
读一个空管道会阻塞。写一个满管道(塞进了65535个字符)也会阻塞。
写一个读端被关闭的管道,会发出SIGPIPE信号。
读一个写端关闭的管道,则read直接返回0。

  • 双管道

这里写图片描述

  • FIFO

FIFO指先进先出数据流,也称为有名管道。性质和管道是一样的。

注意:FIFO不能打开来既读又写,它是半双工。

原型:int mkfifo(const char *pathname, mode_t mode);

pathnaem路径名,mode 如果指定O_CREAT | O_EXCL ,要么创建一个新的FIFO,要么返回一个EEXIST错误。
成功为0 ,出错返回为-1

对管道FIFO的write总是往末尾添加数据,对它们的read则总是从头开始返回数据。如果对管道FIFO调用lseek,则返回ESPIPE错误。

  • 管道和FIFO的额外属性
两种方式设置成非阻塞
(1open时指定O_NONBLOCK标志。
writefd = open(FIFO1,O_WRONLY | O_NONBLOCK,0);

(2)如果描述符已经打开,用fcntl启用O_NONBLOCK标志。

2.Posix 消息队列

Posix消息队列的读总是返回最高优先级的最早消息。随内核持续。

#include <mqueue.h>

创建新的消息队列或打开一个已存在的消息队列:
 mqd_t mq_open(const char *name, int oflag,...
                  /*mode_t mode,struct mq_attr *attr */);

返回:成功为消息队列描述符,出错为-1
消息队列的名字只能以一个 '/'开头,名字中不能包含其他的'/'

oflag 是O_RDONLY | O_WRONLY | O_RDWR 之一,可能按位或上O_CREAT 、O_EXCL或O_NONBLOCK 。
如果指定O_CREAT标志,mode和attr参数是必须要的,attr属性,如果为NULL,则使用默认属性。

关闭消息队列:
int mq_close(mqd_t modes);
返回:成功为0,出错为-1。

要想把消息队列从系统中删除,用mq_unlink。
int mq_unlink(const char *name);
返回:成功为0,出错为-1。


属性:
mq_getattr - 获取消息队列的属性
mq_setattr - 设置消息队列的属性

int mq_getattr(mqd_t mqdes, struct mq_attr *attr);
int mq_setattr(mqd_t mqdes, const struct mq_attr *attr
                struct mq_attr *oattr);
返回:成功为0,出错为-1。

mq_attr结构体:
struct mq_attr {
               long mq_flags;       /* Flags: 0 or O_NONBLOCK */
               long mq_maxmsg;      /* Max. # of messages on queue */
               long mq_msgsize;     /* Max. message size (bytes) */
               long mq_curmsgs;     /* # of messages currently in queue */
};

往队列中放置一个消息或从队列中取走一个消息:mq_send和mq_receive函数

每个消息有一个优先级,小于MQ_PRIO_MAX的无符号整数

mq_receive总是返回指定队列中的优先级最高的最早消息,而且该优先级能随该消息的内容及其长度返回

#include <mqueue.h>
int mq_send(mqd_t mqdes, const char *ptr, size_t len, unsigned int prio);   
成功返回0,失败返回-1

int mq_receive(mqd_t mqdes, char *ptr, size_t lne, unsigned int *prio);  
成功返回消息中的字节数,失败返回-1

消息队列的限制:
有两个限制:

(1) MQ_OPEN_MAX:一个进程同时打开的消息队列的最大数目

(2)MQ_PRIO_MAX:任意消息的最大优先级值 加1

mq_notify函数:

适用情况:往空队列中放置了一个消息

两种方式 :

(1)产生一个信号
(2)创建一个线程执行一个指定的函数

函数定义

#include <mqueue.h>
int mq_notify(mqd_t mqdes, const struct sigevent *notification);

成功返回0,失败返回 -1

信号常值 都 定义在<signal.h>头文件中

union sigval{
   int sival_int;
   void *sival_ptr;
};

struct sigevent{
    int sigev_notify;//通知类型,有SIGEV_(NONE, SIGNAL, THREAD)
    int sigev_signo;//SIGEV_SIGNAL时的信号值
    uniong sigval sigev_value;//传递给信号处理函数或者线程的值
    void (*sigev_notify_function)(union sigval);
    pthread_attr_t *sigev_notify_attributes;
};

函数的若干规则:

(1)notification非空,表示有一个消息到达该队列并且先前为空时得到通知

(2)notification为空,表示注册被撤销

(3)任意时刻只有一个进程可以注册为接收某个队列的通知

(4)当有一个消息到达先前为空的队列时,且有一个进程注册为接收该队列的通知时,只有没有任何线程阻塞在该队列的mq_receive调用的前提下,通知才会发出。即mq_receive调用中的阻塞优先于任何通知的注册

(5)当通知发送给注册进程时,其注册被撤销,须再次调用 mq_notify注册

3.System V 消息队列

先进先出的结构。
这里写图片描述

#include <sys/msg.h>

1.键的生成:
为了得到同一个键,必须使用双方都看得到的东西。文件名是一个选项,为了避免使用不同的文件名而碰巧得到同一个键,再加上一个数字来共同构造一个键。

key_t ftok(const char *pathname, int proj_id);

ftok的原理是:
读取路径(文件)的属性,取出它的dev_no和i_no,前者取8位,后者取16位,然后加上id的低8位,组成一个key。

2.创建或获取消息队列:

int msgget(key_t key, int msgflg);

返回:成功为非负标识符,出错为-1

key是大家共同生成的,msgflg类似于文件的权限,多了一个标志位。
这个标志位有:
IPC_CREAT   //创建
IPC_EXCL     //排他,如果创建时存在则返回-1

在终端可以使用ipcs查看消息队列,使用ipcrm删除以创建的消息队列

3.发送消息:

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

成功返回0,出错为-1

msgp就是指向我们要发送的消息结构体。
这个结构体需要自己创建,要求该结构体第一个参数是long就行了。
典型的结构体如下:
struct msg_t {
long mtype;  //消息的自定义类型
char mtext[1024];  //存储特定的消息字符
};

struct msg_t {
long mtype;  //消息的自定义类型
unsignd int data;//发送的数据
short flag; //同上
};

msgsz是消息的大小,是整个结构体的大小-4 (mtype的大小)。

msgflg是标志位,通常为0,也有一个常用选项就是IPC_NOWAIT,如果消息队列满了就不等待直接返回一个错误。

4.读取消息:

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
                      int msgflg);
返回:成功为读入缓冲区中数据的字节数,出错为-1

其中msgtype指定了要读取的消息类型,如果>0表示读取该消息类型的消息,
=0表示读取队列里 第一条消息,<0读取比该值绝对值小的类型的第一条消息

msgrcv的msgflag指定所请求的类型不在所指定的队列时该如何处理。如果设置了IPC_NOWAIT位,msgrcv立即返回ENOMSG错误,否则阻塞一下事件发生为止:
(1)有一个请求类型的消息可获取
(2)有msqid标识的消息队列被从系统中删除(返回EIDRM错误)
(3)调用线程被某个捕获的信号所中断(返回EINTR错误)

5.消息队列的管理:

一个消息队列对象可以存放16条消息,每条消息最多可以有8192个字节。
这些属性可以通过一个函数设置或者获取。
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msgid是生成的id,
cmd是选项:
IPC_SET:设置属性
IPC_INFO:获取属性
IPC_RMID:删除前面生成的消息队列
IPC_STAT 给调用者返回所指定消息队列对应打枊前msqid_ds结构体(查头文件)

第三部分 同步

1.互斥锁和条件变量


 - 1.互斥锁
用于保护临界区。


锁的初始化:
(1) 静态分配 
例:static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
(2) 动态分配
用pthread_mutex_init 函数初始化。

互斥锁上锁和解锁:
pthread_mutex_t  :数据类型
int pthread_mutex_lock(pthread_mutex_t *mptr):  上锁
int pthread_mutex_unlock(pthread_mutex_t *mptr): 解锁
返回:成功为0,出错为正的Exxx值    都为阻塞函数

int pthread_mutex_trylock(pthread_mutex_t *mptr);
非阻塞函数,如果互斥锁已经锁住,返回一个EBUSY错误

 - 2.条件变量
互斥锁用于上锁,条件变量用于等待。

pthread_cond_t : 数据类型
pthread_cond_wait(pthread_cond_t *cptr,pthread_mutex_t *mptr): 睡眠等待
pthread_cond_signal(pthread_cond_t *cptr):  唤醒等待的线程
pthread_cond_broadcast(pthread_cond_t *cptr): 通知所有等待的线程

通常pthread_cond_signal只是唤醒等待在相应条件变量上的一个线程,在某些情况下需要唤醒多个线程(例如读写者问题),可以调用pthread_cond_broadcast唤醒阻塞在相应条件变量上的所有线程。


pthread_mutex_init:  初始化互斥锁
pthread_mutex_destroy:  从系统中摧毁锁
pthread_cond_init:  初始化条件变量
pthread_cond_destroy: 

2.读写锁


读写锁的分配规则:
(1)只要没有线程持有某个特定的读写锁用于写,那么任意数目的线程可以持有该读写锁用于读。
(2)仅当没有线程持有某个给定的读写锁用于读或用于写时,才能分配该读写锁用于写。

获取与释放读写锁:
#include<pthread.h>  
int pthread_rwlock_rdlock(pthread_rwlock_t *rwptr)  读出锁
int pthread_rwlock_wrlock(pthread_rwlock *rwptr)   写入锁
int pthread_rwlock_unlock(pthread_rwlock *rwptr)  解锁

返回:成功为0 ,出错为正的Exxx值

下面两个函数尝试获取一个读出锁或写入锁,但是如果不能马上获得,那就返回一个EBUSY错误,而不是把调用线程投入睡眠,也就是不阻塞。

int pthread_rwlock_tryrdlock(pthread_rwlock *rwptr)  
int pthread_rwlock_trywrlock(pthread_rwlock *rwptr)  

读写锁属性:

读写锁变量可以通过pthread_rwlock_init来动态初始化,当一个线程不再需要某个读写锁时,可以调用pthread_rwlock_destory来摧毁它

int  pthread_rwlock_init(pthread_rwlock_t *rwptr,const pthread_rwlockattr_t *attr)  
int pthread_rwlock_destory(pthread_rwlock_t *rwptr)  

初始化时,attr是个空指针,那么读写锁使用默认属性。如果要赋予它非默认属性,需要使用下面两个函数

int pthread_rwlockattr_init(pthread_rwlockattr_t *attr)  

int pthread_rwlockattr_destory(pthread_rwlockattr *attr)  

当期定义了唯一的属性是PTHREAD_PROCESS_SHARED,它指定读写锁在不同进程间共享,二不仅仅是在单个进程内的不同线程共享,下面两个函数分别获取和设置这个属性

int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *attr,int *valptr)  

int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int value)  

 - 线程取消

 一个线程可以被同一进程的任何其他线程所取消。
int pthread_cancel(pthread_t tid);

如果pthread_rwlock_rdlock的调用线程阻塞在pthread_cond_wait调用上,随后被统一进程中的其他线程取消,它就在仍然持有互斥锁的情况下终止。

我们可以在原有的代码中加入两行:

rw->rw_nreaders++;  
pthread_cleanup_push(rwlock_cancelrdwait,(void *arg))//添加清理处理程序      
result=pthread_cond_wait(&rw->rw_condreaders,&rw->rw_mutex);  
pthread_cleanup_pop(0);   //删除清理处理程序  
 rw->rw_nreaders--;  

3.记录上锁

记录上锁是读写锁的一种扩展类型,它可用于任意两个进程间共享某文件的读和写。执行上锁的函数是fcntl,锁由内核维护,其属主由进程ID标识。

Unix内核没有记录这一概念,对记录的解释是由读写文件的应用进行的。每个记录就是文件中的一个字节范围。

Posix记录上锁的粒度是单个字节,粒度越小,允许同时使用的用户数就越多。
Posix记录锁为劝告性上锁。

Posix fcntl 记录上锁

int fcntl(int fd, int cmd, .../* struct flock *arg*/);

第三个参数(flockptr),指向一个flock结构指针,flock的结构如下:
 struct flock {
        short l_type;/*F_RDLCK, F_WRLCK, or F_UNLCK*/
        off_t l_start;/*相对于l_whence的偏移值,字节为单位*/
        short l_whence;/*从哪里开始:SEEK_SET, SEEK_CUR, or SEEK_END*/
        off_t l_len;/*长度, 字节为单位; 0 意味着缩到文件结尾*/
        pid_t l_pid;/*returned with F_GETLK*/
 };

fcntl函数的cmd参数为以下三个值时,执行记录上锁的相关操作:

1.   F_SETLK 获取(l_type成员为F_RDLCK或F_WRLCK)或释放(F_UNLCK)指定的锁,若无法完成该操作则返回出错而不阻塞。

2.   F_SETLKW 阻塞版本的F_SETLK。

3.   F_GETLK 检查arg指向的锁是否与某个已存在的锁冲突。

F_GETLK后紧接着F_SETLK不是原子操作。

fcntl不能对只读打开的文件获取写锁,也不能对只写打开的文件获取读锁。

锁住整个文件的两个方式:

1.   l_whence成员为SEEK_SET,l_start的成员为0,l_len成员为02.   用lseek把读写指针放到文件头,然后令l_whence为SEEK_SET,l_start为0,l_len为0。

某个文件描述符被关闭时,与它关联的记录锁都被删除。记录锁不能通过fork子进程继承。

记录锁不应该同标准I/O函数一起使用,因为标准I/O库使用了缓冲。

NFS可以使用记录锁。

4.Posix 信号量

信号量(semaphore)是一种提供不同进程间或者一个给定进程不同线程之间的同步。
Posix两类信号量:有名信号量和基于内存的信号量。

Posix信号量的三种操作:
(1)创建(create)
(2)等待(wait)
(3)挂出(post)

1.sem_init()初始化无名信号量(基于内存的信号量)

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

int sem_destroy(sem_t *sem); //摧毁基于内存的信号量

//pshared指定该信号量用于进程还是线程同步
//0-表示用于线程同步(所有线程可见)
//非0-表示用于进程同步(需要放在共享内存中)

2.sem_open()初始化有名信号量

#include <semaphore.h>
sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);

成功返回指向信号量的指针,出错为SEM_FAILED

Link with -pthread.

//oflag可以设置为O_CREAT 或 O_CREAT | O_EXCL (如果存在则返回错误)。
//mode可以设置为0644 自己可读写,其他用户和组内用户可读
//value表示信号量初始化的值,二值信号量的初始值通常为1,计数信号量的初始值往往大于1。

不同进程间可以访问同一个有名信号量,sem_open时指定相同名字就行。

关闭函数:

int sem_close(sem_t *sem);
成功为0,出错为-1

从系统中删除有名信号量:

int sem_unlink(const char *name);
成功为0,出错为-1

3.sem_wait()和sem_post()等待和挂出函数

#include <semaphore.h>
int sem_wait(sem_t *sem);//P操作  -1
int sem_post(sem_t *sem);//V操作 +1

int sem_trywait(sem_t *sem);
信号量为0 不睡眠,返回EAGAIN错误
Link with -pthread.


4.sem_getvalue 函数

int sem_getvalue(sem_t *sem , int *valp);

valp 指向整数中返回指定信号量的当前值。如果该信号量已上锁,返回当前值或为0,或为负数,其绝对值就是等待该信号量解锁的线程数。

5.System V 信号量

System V信号量一般指信号量集,而Posix 信号量一般指单个计数信号量。
与消息队列和共享内存一样,信号量集也有自己的数据结构:

struct semid_ds {
struct ipc_perm sem_perm; /* Ownership and permissions */
time_t sem_otime; /* Last semop time */
time_t sem_ctime; /* Last change time */
unsigned short sem_nsems; /* No. of semaphores in set */
};

同样地,第一个条目sem_perm 也是共有的ipc 对象内核结构,剩下的是私有成员。

struct sem{
unsigned short semval; /* semaphore value */
unsigned short semzcnt; /* # waiting for zero */
unsigned short semncnt; /* # waiting for increase */
pid_t sempid; /* process that did last op */
}

即每一个在信号量集中的信号量都有上述4个相关的变量。

典型的做法:

令i=1;
A进程进入临界区,先-1,看看是否==0,如果真则进入临界区。
B进程同时准备进入临界区,也要-1,但是结果不为0,睡眠等待。

A出临界区,+1,唤醒其它进程。
B 将i-1,此时结果为0,进入临界区,执行。

以上的操作中,-1这个过程叫做P操作。+1的过程叫做V操作。总的来说就是PV操作。

具体由信号量(semaphore,也叫旗语)来实现。

相关的函数都是以sem开头的。

(1)信号量对象的创建
int semget(key_t key, int nsems, int semflg);

nsems参数指定集合中的信号量数。访问一个已存在的集合,该参数指定为0。
semflag 为IPC_CREAT 或 IPC_CREAT | IPC_EXCL。


(2)信号量的管理
int semctl(int semid, int semnum, int cmd, .../*union senum arg*/);

semnum是信号量对象里的信号量的编号。从0开始的。

cmd有如下的选项:
GETVAL: 获取单个信号量的值。
SETVAL:  设置单个信号量的值。
GETALL:  获取所有信号量的值。
SETALL:  设置所有信号量的值。

还有其他命令参考操作手册。

设置和读取需要第4个参数:

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) */
           };
设置单个信号量,只需赋值val既可以。如果要同时设置多个信号量,就要使用array数组。

(3)pv操作
int semop(int semid, struct sembuf *sops, size_t nsops);

struct sembuf{
    unsigned short sem_num;  /* semaphore number */
    short          sem_op;   /* semaphore operation */
    short          sem_flg;  /* operation flags */

};

第一个成员是信号量编号,第二个是操作,有+n,-n操作,减是p操作就是获取信号量,加是v操作,释放信号量。
第三个成员的标识,通常可以设置为0,有两个选项:
SEM_UNDO: 进程崩溃或退出后,内核会恢复信号量的初始值。
SEM_NOWAIT: 不等待。

参数中nsops是结构体数组的大小。本函数可以同时操作多个结构体,只需要告诉数量就可以了。

第四部分 共享内存区

1.共享内存区介绍

文件的内存映射:

将文件按一页一页的方式放入内存的页中,然后进行映射。

mmap函数和munmap函数

内存映射用mmap完成。
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr是内存中开始映射的地址,通常为0,由系统选择。
length是映射的长度,不足1页,也会使用1页。
prot是保护方式,有PROT_READ, PROT_WRITE, PROT_EXCUTE
flags是共享方式, 有MAP_SHARED 共享, MAP_PRIVATE私有, MAP_ANON匿名
fd是要映射的文件描述符,如果是匿名映射则使用-1,falg用MAP_SHARED |MAP_ANON
offset是偏移的量,必须是页的整数倍。

返回值就是文件映射到内存里的起始地址。如果失败,返回的是MAP_FAILED宏。

注意:
mmap里的读写权限要和文件打开时的读写权限一致,另外不要对只读文件进行内容修改。

从某个进程的地址空间删除一个映射关系:

int munmap(void *addr ,size_t len);

返回:成功为0,出错为-1

2.Posix 共享内存区

两种无亲缘关系进程间共享内存的方法:

  1. 内存映射文件:open一个普通文件,用它的fd进行mmap。

  2. 共享内存区对象:shm_open一个IPC名字,用它的fd进行mmap。步骤:指定名字调用shm_open(..)创建共享内存 ,然后调用mmap将共享内存映射到调用进程。

int shm_open(constchar *name, int oflag, mode_t mode); (调用shm_open前一般先调用shm_unlink以提防所需共享内存区对象已经存在的情况)

返回:成功为非负描述符,出错为-1。
创建或打开一个Posix共享内存区对象。Posix没有指定一个新建的共享内存区对象的初始内容。

int shm_unlink(constchar *name);(删除一个名字不会影响对于其底层支撑对象的现有引用,知道对于该对象引用的全部关闭为止,删除一个名字仅仅防止后续的open mq_open  sem_open调用取得成功)

引用计数删除。

int ftruncate(int fd, off_t length);

改变文件或共享内存区对象的大小。

int fstat(int fd,struct stat *buf);

获取文件信息,对共享内存区对象只有4个成员有信息:


注意:

同一共享内存区对象内存映射到不同进程的地址空间时,起始地址可以不一样。

3.System V 共享内存区

步骤:先调用shmget,再调用shmat。

int shmget(key_t key, size_t size, int shmflg);

返回:成功为共享内存区对象,出错为-1。

void *shmat(int shmid, const void *shmaddr, int shmflg);

将创建的内存附到本进程,就可以访问,多个进程都执行的话,就可以访问同一块内存。

返回:成功为映射区的起始地址,出错为-1。

shmid为shmget返回的标识符。
shmaddr为NULL,则系统自动选择地址(可移植性最好的方法)
shmflag通常为0int shmdt(const void *shmaddr);

dt是detattach的意思,脱离本进程。不删除内存。如果要删除使用shmctl函数加上SHM_RMID这个选项。

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

cmd:一些命令

    IPC_STAT 得到共享内存的状态
    IPC_SET 改变共享内存的状态
    IPC_RMID 删除共享内存 

IPC_RMID 命令实际上不从内核删除一个段,而是仅仅把这个段标记为删除,实际的删除发生在最后一个进程离开这个共享段时。 

注意:
共享内存不会随着程序结束而自动消除,要么调用shmctl删除,要么自己用手敲命令去删除,否则永远留在系统中。
  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值