UNIX 共享内存应用中的问题及解决方法

简介

共享内存是一种非常重要且常用的进程间通信方式,相对于其它IPC机制,因其速度最快、效率最高,被广泛应用于各类软件产品及应用开发中。System V IPC 为UNIX平台上的共享内存应用制定了统一的API标准,从而为在UNIX/Linux平台上进行跨平台开发提供了极大的便利;开发人员基于一套基本相同的源代码,便可开发出同时支持AIX、Solaris、HP-UX、Linux等平台的产品。

然而,各个平台对System V 标准的API在实现上各有差异,由此对相关应用开发带来影响,甚至引入难以调试的问题。本文将结合作者在Tivoli产品开发中的实际经验,对这些平台相关的问题,以及具有共性的问题,逐一进行分析,并提出解决方法。





回页首


1. System V共享内存概述

System V 进程间通信(IPC)包括3种机制:消息队列、信号量、共享内存。消息队列和信号量均是内核空间的系统对象,经由它们的数据需要在内核和用户空间进行额外的数据拷贝;而共享内存和访问它的所有应用程序均同处于用户空间,应用进程可以通过地址映射的方式直接读写内存,从而获得非常高的通信效率。

System V 为共享内存定义了下列API接口函数:


# include <sys/types.h>
# include <sys/ipc.h>
# include <sys/shm.h>

key_t	 ftok(const char *pathname, int proj_id);
int	 shmget(key_t key, int size, int shmflg);
void*	 shmat(int shmid, const void *shmaddr, int shmflg);
int	 shmdt(void *shmaddr);
int	 shmctl(int shmid, int cmd, struct shmid_ds *buf);

ftok函数用于生成一个键值:key_t key,该键值将作为共享内存对象的唯一性标识符,并提供给为shmget函数作为其输入参数;ftok 函数的输入参数包括一个文件(或目录)路径名:pathname,以及一个额外的数字:proj_id,其中pathname所指定的文件(或目录)要求必须已经存在,且proj_id不可为0;
shmget函数用于创建(或者获取)一个由key键值指定的共享内存对象,返回该对象的系统标识符:shmid;
shmat函数用于建立调用进程与由标识符shmid指定的共享内存对象之间的连接;
shmdt函数用于断开调用进程与共享内存对象之间的连接;
shmctl函数用于对已创建的共享内存对象进行查询、设值、删除等操作;




回页首


2. ftok的陷阱

根据pathname指定的文件(或目录)名称,以及proj_id参数指定的数字,ftok函数为IPC对象生成一个唯一性的键值。在实际应用中,很容易产生的一个理解是,在proj_id相同的情况下,只要文件(或目录)名称不变,就可以确保ftok返回始终一致的键值。然而,这个理解并非完全正确,有可能给应用开发埋下很隐晦的陷阱。因为ftok的实现存在这样的风险,即在访问同一共享内存的多个进程先后调用ftok函数的时间段中,如果pathname指定的文件(或目录)被删除且重新创建,则文件系统会赋予这个同名文件(或目录)新的i节点信息,于是这些进程所调用的ftok虽然都能正常返回,但得到的键值却并不能保证相同。由此可能造成的后果是,原本这些进程意图访问一个相同的共享内存对象,然而由于它们各自得到的键值不同,实际上进程指向的共享内存不再一致;如果这些共享内存都得到创建,则在整个应用运行的过程中表面上不会报出任何错误,然而通过一个共享内存对象进行数据传输的目的将无法实现。

AIX、Solaris、HP-UX均明确指出,key文件被删除并重建后,不保证通过ftok得到的键值不变,比如AIX上ftok的man帮助信息即声明:

Attention: If the Path parameter of the ftok subroutine names a file that has been removed while keys still refer to it, the ftok subroutine returns an error. If that file is then re-created, the ftok subroutine will probably return a key different from the original one.

Linux没有提供类似的明确声明,但我们可以通过下面的简单例程test01.c,得到相同的印证:


#include <stdio.h>
#include <sys/ipc.h>
void main(int argc, char* argv[])
{
if (argc !=2 ) {
		printf("Usage: %s KeyFile/n e.g. %s /tmp/mykeyfile/n", argv[0], argv[0]);
		return;
	}
	printf("Key generated by ftok:  0x%x/n", ftok(argv[1], 1));
}

将上述例程在Red Hat Enterprise Linux AS release 4平台上编程成可执行程序test01,并且通过touch命令在 /tmp目录下创建一个新文件mykeyfile,然后为该文件生成键值:


# touch  /tmp/mykeyfile
# ./test01 /tmp/mykeyfile
Key generated by ftok:  0x101000b

然后,将/tmp/mykeyfile删除,并且通过vi命令重新创建该文件,再次生成键值:


# ./test01 /tmp/mykeyfile
Key generated by ftok:  0x1010017

我们可以看到,虽然文件名称都是 /tmp/mykeyfile,并未改变,但由于中间发生了文件删除并重新创建的操作,前后两次所得到的键值已经不再相同。

避免此类问题最根本的方法,就是采取措施保证pathname所指定的文件(或目录)在共享内存的使用期间不被删除,不要使用有可能被删除的文件;或者干脆直接指定键值,而不借助ftok来获取键值。





回页首


3. AIX中shmat的问题

AIX系统中,System V各类进程间通信机制在使用中均存在限制。区别于其它UNIX操作系统对IPC机制的资源配置方式,AIX使用了不同的方法;在AIX中定义了 IPC 机制的上限, 且是不可配置的。就共享内存机制而言,在4.2.1及以上版本的AIX系统上,存在下列限制:

  • 对于64位进程,同一进程可连接最多268435456个共享内存段;
  • 对于32位进程,同一进程可连接最多11个共享内存段,除非使用扩展的shmat;

上述限制对于64位应用不会带来麻烦,因为可供连接的数量已经足够大了;但对于32位应用,却很容易带来意外的问题,因为最大的连接数量只有11个。在某些事件触发的多线程应用中,新的线程不断地为进行事件处理而被创建,这些线程如果都需要去连接特定的共享内存,则极有可能造成该进程连接的共享内存数量超过11个,事实上同时拥有几十个甚至上百个处理线程的应用并不少见。一旦超个这个限制值,则所有后续的处理线程都将无法正常工作,从而导致应用运行失败。

下面的例程test02.c演示了这个问题,为了精简代码,它反复连接的是同一个共享内存对象;实际上,无论所连接的共享内存对象是否相同,该限制制约的是连接次数:


#include 	<stdio.h>
#include 	<errno.h>	
#include 	<sys/types.h>
#include 	<sys/ipc.h>
#include 	<sys/shm.h>
#define 	MAX_ATTACH_NUM  15

void main(int argc, char* argv[])
{
    key_t       mem_key;
    long        mem_id;
    void*       mem_addr[MAX_ATTACH_NUM];
    int          i;
    if ( ( mem_key = ftok("/tmp/mykeyfile", 1) ) == (key_t)(-1) )  {
            printf("Failed to generate shared memory access key, ERRNO=%d/n",
			errno);
            goto MOD_EXIT;
    }
    if ( ( mem_id = shmget(mem_key, 256, IPC_CREAT) ) == (-1) )  {
            printf("Failed to obtain shared memory ID, ERRNO=%d/n", errno);
            goto MOD_EXIT;
    }
    for ( i=1; i<=MAX_ATTACH_NUM; i++ )  {
		if ( ( mem_addr[i] = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )
			printf("Failed to attach shared memory, times [%02d], errno:%d/n", i,
			errno);
		else
			printf("Successfully attached shared memory, times [%02d]/n", i);
    }
MOD_EXIT:
    shmctl(mem_id, IPC_RMID, NULL);
}

在AIX系统上,我们将其编译为test02,并运行,可以看到如下输出:


Successfully attached shared memory, times [01]	
Successfully attached shared memory, times [02]	
Successfully attached shared memory, times [03]	
Successfully attached shared memory, times [04]	
Successfully attached shared memory, times [05]	
Successfully attached shared memory, times [06]	
Successfully attached shared memory, times [07]	
Successfully attached shared memory, times [08]	
Successfully attached shared memory, times [09]	
Successfully attached shared memory, times [10]	
Successfully attached shared memory, times [11]	
Failed to attach shared memory, times [12], errno:24
Failed to attach shared memory, times [13], errno:24
Failed to attach shared memory, times [14], errno:24
Failed to attach shared memory, times [15], errno:24

说明超出11个连接之后,所有后续的共享内存连接都将无法建立。错误码24的定义是EMFILE,AIX给予的解释是:

The number of shared memory segments attached to the calling process exceeds the system-imposed limit。

解决这个问题的方法是,使用扩展的shmat;具体而言就是,在运行相关应用之前(确切地说,是在共享内存被创建之前),首先在shell中设置EXTSHM环境变量,通过它扩展shmat,对于源代码本身无需作任何修改:


		export EXTSHM=ON
	

值得注意的是,虽然设置环境变量,在程序中也可通过setenv函数来做到,比如在程序的开始,加入下列代码:


		setenv("EXTSHM", "ON", 1);
	

但实践证明这样的方法在解决这个问题上是无效的;也就是说唯一可行的办法,就是在shell中设置EXTSHM环境变量,而非在程序中。

在AIX上配置32位DB2实例时,也要求确保将环境变量 EXTSHM 设为 ON,这是运行 Warehouse Manager 和 Query Patroller 之前必需的操作:


export EXTSHM=ON
db2set DB2ENVLIST=EXTSHM
db2start

其原因即来自我们刚刚介绍的AIX中32位应用连接共享内存时,存在最大连接数限制。这个问题同样普遍存在于AIX平台上Oracle等软件产品中。





回页首


4. HP-UX中shmget和shmat的问题

4.1 32位和64位应用兼容问题

在HP-UX平台上,如果同时运行32位应用和64位应用,而且它们访问的是一个相同的共享内存区,则会遇到兼容性问题。

在HP-UX中,应用程序设置IPC_CREAT标志调用shmget,所创建的共享内存区,只可被同类型的应用所访问;即32位应用程序所创建的共享内存区只可被其它的32位应用程序访问,同样地,64位应用程序所创建的共享内存区只可被其它的64位应用程序访问。

如果,32位应用企图访问一个由64位应用创建的共享内存区,则会在调用shmget时失败,得到EINVAL错误码,其解释是:

A shared memory identifier exists for key but is in 64-bit address space and the process performing the request has been compiled as a 32-bit executable.

解决这一问题的方法是,当64位应用创建共享内存时,合并IPC_CREAT标志,同时给定IPC_SHARE32标志:


shmget(mem_key, size, 0666 | IPC_CREAT | IPC_SHARE32)

对于32位应用,没有设定IPC_SHARE32标志的要求,但设置该标志并不会带来任何问题,也就是说无论应用程序将被编译为32位还是64位模式,都可采用如上相同的代码;并且由此解决32位应用和64位应用在共享内存访问上的兼容性问题。

4.2 对同一共享内存的连接数限制

在HP-UX上,应用进程对同一个共享内存区的连接次数被限制为最多1次;区别于上面第3节所介绍的AIX上的连接数限制,HP-UX并未对指向不同共享内存区的连接数设置上限,也就是说,运行在HP-UX上的应用进程可以同时连接很多个不同的共享内存区,但对于同一个共享内存区,最多只允许连接1次;否则,shmat调用将失败,返回错误码EINVAL,在shmat的man帮助中,对该错误码有下列解释:

shmid is not a valid shared memory identifier, (possibly because the shared memory segment was already removed using shmctl(2) with IPC_RMID), or the calling process is already attached to shmid.

这个限制会对多线程应用带来无法避免的问题,只要一个应用进程中有超过1个以上的线程企图连接同一个共享内存区,则都将以失败而告终。

解决这个问题,需要修改应用程序设计,使应用进程具备对同一共享内存的多线程访问能力。相对于前述问题的解决方法,解决这个问题的方法要复杂一些。

作为可供参考的方法之一,以下介绍的逻辑可以很好地解决这个问题:

基本思路是,对于每一个共享内存区,应用进程首次连接上之后,将其键值(ftok的返回值)、系统标识符(shmid,shmget调用的返回值)和访问地址(即shmat调用的返回值)保存下来,以这个进程的全局数组或者链表的形式留下记录。在任何对共享内存的连接操作之前,程序都将先行检索这个记录列表,根据键值和标志符去匹配希望访问的共享内存,如果找到匹配记录,则从记录中直接读取访问地址,而无需再次调用shmat函数,从而解决这一问题;如果没有找到匹配目标,则调用shmat建立连接,并且为新连接上来的共享内存添加一个新记录。

记录条目的数据结构,可定义为如下形式:


typedef struct  _Shared_Memory_Record
{
	key_t		mem_key;		// key generated by ftok()			
	int			mem_id;			// id  returned by shmget()			
	void*		mem_addr;		// access address returned by shmat()	
	int			nattach;			// times of attachment				
} Shared_Memory_Record;

其中,nattach成员的作用是,记录当前对该共享内存区的连接数目;每一次打开共享内存的操作都将对其进行递增,而每一次关闭共享内存的操作将其递减,直到nattach的数值降到0,则对该共享内存区调用shmdt进行真正的断开连接。

打开共享内存的逻辑流程可参考如下图一:


图一
图一

关闭共享内存的逻辑流程可参考如下图二:


图二
图二




回页首


5. Solaris中的shmdt函数原型问题

Solaris系统中的shmdt调用,在原型上与System V标准有所不同,


    Default
     int shmdt(char *shmaddr);
  

即形参shmaddr的数据类型在Solaris上是char *,而System V定义的是void * 类型;实际上Solaris上shmdt调用遵循的函数原型规范是SVID-v4之前的标准;以Linux系统为例,libc4和libc5 采用的是char * 类型的形参,而遵循SVID-v4及后续标准的glibc2及其更新版本,均改为采用void * 类型的形参。

如果仍在代码中采用System V的标准原型,就会在Solaris上编译代码时造成编译错误;比如:


Error: Formal argument 1 of type char* in call to shmdt(char*) 
is being passed void*.

解决方法是,引入一个条件编译宏,在编译平台是Solaris时,采用char * 类型的形参,而对其它平台,均仍采用System V标准的void * 类型形参,比如:


		#ifdef  _SOLARIS_SHARED_MEMORY         
	shmdt((char *)mem_addr);	
#else              			
	shmdt((void *)mem_addr);	
#endif
	





回页首


6. 通过shmctl删除共享内存的风险

当进程断开与共享内存区的连接后,一般通过如下代码删除该共享内存:


		shmctl(mem_id, IPC_RMID, NULL);	
	

从HP-UX上shmctl函数的man帮助,我们可以看到对IPC_RMID操作的说明:

IPC_RMID Remove the shared memory identifier specified by shmid from the system and destroy the shared memory segment and data structure associated with it. If the segment is attached to one or more processes, then the segment key is changed to IPC_PRIVATE and the segment is marked removed. The segment disappears when the last attached process detaches it.

其它UNIX平台也有类似的说明。关于shmctl的IPC_RMID操作,其使用特点可简述如下:

  • 如果共享内存已经与所有访问它的进程断开了连接,则调用IPC_RMID子命令后,系统将立即删除共享内存的标识符,并删除该共享内存区,以及所有相关的数据结构;
  • 如果仍有别的进程与该共享内存保持连接,则调用IPC_RMID子命令后,该共享内存并不会被立即从系统中删除,而是被设置为IPC_PRIVATE状态,并被标记为"已被删除";直到已有连接全部断开,该共享内存才会最终从系统中消失。

    于是,存在这样的一种状态:

    • N个进程(进程1至进程N)已经与某共享内存区连接;
    • 进程1已完成对此共享内存的操作,断开连接后,调用shmctl的IPC_RMID子命令,企图删除该共享内存;
    • 由于进程2至进程N仍保持与该共享内存的连接,因此在它们全部断开连接之前,这个共享内存区毫无疑问地会依然存在。

此时,如果有其它的进程(比如第N+1号进程)想建立对这个共享内存的连接,是否能够成功呢?

类似的状态,在Windows上同样存在,只是程序借助的API有所不同,比如通过CreateFileMapping函数创建共享内存,通过MapViewOfFile函数建立连接,通过UnmapViewOfFile函数断开连接,通过CloseHandle函数删除共享内存等。在Windows上,对此问题的回答是肯定的;也就是说,只要共享内存依然存在,则进程总是可以建立对它的连接,而无论之前是否有进程对其执行过删除操作。

然而,对于包括AIX、Solaris、HP-UX等在内的UNIX平台,答案却是否定的!这也正是本节所讨论的使用shmctl中的风险所在;通过以下test03.P1.c和test03.P2.c两个例程,我们可以很直观地得到答案:

test03.P1.c: 创建共享内存,并建立连接,保持10秒后(在此期间,test03.P2将反复连接、并删除该共享内存),断开连接,并最后再次尝试连接以验证该共享内存是否已被真正删除;

test03.P2.c: 反复连接由test03.P1创建的共享内存,并在期间通过shmctl的IPC_RMID 子命令删除该共享内存,以观察共享内存被执行删除操作之后,在被彻底销毁之前是否还能接受连接;


/******* test03.P1.c ********/
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

int main(int argc, char* argv[])
{
	key_t       	mem_key;
    long 		mem_id;
    void* 		mem_addr;
	int			isAttached = 0;

    mem_key = ftok("/tmp/mykeyfile", 1);
    mem_id  = shmget(mem_key, 256, IPC_CREAT);

    if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )  
    	printf("%s, Failed to attach shared memory, errno:%d/n", argv[0], errno);
    else { 
    	isAttached = 1;
        printf("%s, +.Successfully attached shared memory/n", argv[0]);
   	}

	/* sleep 10 seconds, to wait test03.P2 to run */ 
	sleep(10);

	if (isAttached) { 
		// Attention: the following line should be "shmdt((char *)mem_addr);" if
		on Solaris 
		shmdt((void *)mem_addr); 
		printf("%s, -.Successfully detached shared memory/n", argv[0]);
	} 

	/* try to attach the shared memory which has been removed! */ 
	if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) ) 
		printf("%s, Failed to attach the removed shared memory, errno:%d/n",
		argv[0], errno); 

  	return 0;
}


/******* test03.P2.c ********/
#include <stdio.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

int main(int argc, char* argv[]) 
{
	key_t 		mem_key; 
   	long   		mem_id; 
    void*   		mem_addr; 
	int			i,  isAttached; 

    mem_key = ftok("/tmp/mykeyfile", 1); 
    mem_id  = shmget(mem_key, 0, 0); 

	// repeated attaching & detaching 
	for (i=1; i<10; i++) { 
		isAttached = 0; 
		if ( ( mem_addr = (void *)shmat(mem_id, 0, 0) ) == (void *)(-1) )
        	printf("%s, Failed to attach shared memory, times [%02d],
			errno:%d/n",
					  argv[0], i, errno); 
	   	else {
       		isAttached = 1;
          	printf("%s, +.Successfully attached shared memory, times
			[%02d]/n",argv[0], i); 
        }

   		if (isAttached) {
      		// Attention: the following line should be "shmdt((char
			*)mem_addr);", if on Solaris
            shmdt((void *)mem_addr); 
       		printf("%s, -.Successfully detached, times [%02d]/n", argv[0], i);
       	}

		// purposely remove the shared memory at times [5]
		if (i==5) { 
			shmctl(mem_id, IPC_RMID, NULL);
			printf("%s, *.Remove executed, times [%02d], errno=%d/n", 
			argv[0], i, errno);
		}
	} 

 	return 0; 
}

上述程序均可在AIX、HP-UX、Linux平台上编译通过;在Solaris平台上只需按注释提示的要求,将shmdt的参数强制为char *类型也可编译通过(第5节中已介绍过)。

将test03.P1.c、test03.P2.c各自编译为可执行程序test03.P1、test03.P2,并通过下面的shell脚本:runtest,运行它们:


	#!/bin/sh 
	./test03.P1& 
	sleep 2 
	./test03.P2 

在Linux平台(Red Hat 8.0)上的运行结果如下:


[root@localhost tmp]# ./runtest
./test03.P1, +.Successfully attached shared memory
./test03.P2, +.Successfully attached shared memory, times [01]
./test03.P2, -.Successfully detached, times [01]
./test03.P2, +.Successfully attached shared memory, times [02]
./test03.P2, -.Successfully detached, times [02]
./test03.P2, +.Successfully attached shared memory, times [03]
./test03.P2, -.Successfully detached, times [03]
./test03.P2, +.Successfully attached shared memory, times [04]
./test03.P2, -.Successfully detached, times [04]
./test03.P2, +.Successfully attached shared memory, times [05]
./test03.P2, -.Successfully detached, times [05]
./test03.P2, *.Remove executed, times [05], errno=0
./test03.P2, +.Successfully attached shared memory, times [06]
./test03.P2, -.Successfully detached, times [06]
./test03.P2, +.Successfully attached shared memory, times [07]
./test03.P2, -.Successfully detached, times [07]
./test03.P2, +.Successfully attached shared memory, times [08]
./test03.P2, -.Successfully detached, times [08]
./test03.P2, +.Successfully attached shared memory, times [09]
./test03.P2, -.Successfully detached, times [09]
[root@localhost tmp]# ./test03.P1, -.Successfully detached shared memory
./test03.P1, Failed to attach the removed shared memory, errno:22

根据运行结果,我们可以看到,在Linux平台上,即便对共享内存执行了删除操作(在第5次连接之后,test03.P2进程调用了shmctl的IPC_RMID删除操作),只要该共享内存依然存在(test03.P1进程保持着连接,因此共享内存不会被立即删除),则它仍然是可连接的(test03.P2进程的第6到第9次连接均是成功的)。

然而,在AIX、HP-UX、Solaris平台上的运行结果却不同于Linux:


# ./runtest
./test03.P1, +.Successfully attached shared memory
./test03.P2, +.Successfully attached shared memory, times [01]
./test03.P2, -.Successfully detached, times [01]
./test03.P2, +.Successfully attached shared memory, times [02]
./test03.P2, -.Successfully detached, times [02]
./test03.P2, +.Successfully attached shared memory, times [03]
./test03.P2, -.Successfully detached, times [03]
./test03.P2, +.Successfully attached shared memory, times [04]
./test03.P2, -.Successfully detached, times [04]
./test03.P2, +.Successfully attached shared memory, times [05]
./test03.P2, -.Successfully detached, times [05]
./test03.P2, *.Remove executed, times [05], errno=0
./test03.P2, Failed to attach shared memory, times [06], errno:22
./test03.P2, Failed to attach shared memory, times [07], errno:22
./test03.P2, Failed to attach shared memory, times [08], errno:22
./test03.P2, Failed to attach shared memory, times [09], errno:22
# ./test03.P1, -.Successfully detached shared memory
./test03.P1, Failed to attach the removed shared memory, errno:22

根据结果,可以发现,test03.P2进程的第6到第9次连接都是失败的,也就说明,在AIX、HP-UX、Solaris平台上一旦通过shmctl对共享内存进行了删除操作,则该共享内存将不能再接受任何新的连接,即使它依然存在于系统中!

而且,上面的运行结果,也证明了,对共享内存进行了删除操作之后,当已有的连接全部断开,该共享内存将被系统自动销毁(运行结果的最后一行,说明该共享内存已经不存在了)。

本节的目的在于说明,在AIX、HP-UX、Solaris平台上调用shmctl的IPC_RMID删除操作,是存在潜在风险的,需要足够的谨慎。

如果,可以确知,在删除之后不可能再有新的连接,则执行删除操作是安全的;

否则,在删除操作之后如仍有新的连接发生,则这些连接都将失败!





回页首


7. 结论

对共享内存的操作,往往是产品或者应用中数据传输的基础,对其可靠性和性能至关重要;而且作为底层的IPC机制,相关代码具有不易调试的特点,由其造成的问题往往关键却不容易解决。

本文从应用实现的角度上,对在UNIX/Linux平台上使用共享内存可能会遇到的问题,进行了全面的介绍和分析,并给出了解决方法或建议,可供相关的应用开发人员参考。



参考资料



关于作者

 

刘新华,IBM 中国软件开发中心软件工程师。参与过 IBM Rational、Tivoli 等产品开发,最近从事 IBM Tivoli OMEGAMON XE for Message Transaction Tracking 的产品开发工作,对 Linux 内核技术、以及实时和嵌入式应用也有浓厚的兴趣。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值