socket多进程网络编程逐步解析

1.fork()函数创建子进程

  • 头文件
    #include<unistd.h>
    #include<sys/types.h>
  • 函数原型
    pid_t fork( void);
    (pid_t 是一个宏定义,其实质是int,被定义在#includesys/types.h>中)
    返回值: 若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1
  • 函数说明
    一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。
    在这里插入图片描述
    子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。
    在不同的UNIX系统下,我们无法确定fork之后是子进程先运行还是父进程先运行,这依赖于系统的实现。

fork()函数示例代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/wait.h>

int main(int argc, char *argv)
{
        pid_t           pid;
        pid = fork();
        
        if(pid < 0)
        {
                printf("fork() create child process failure:%s\n", strerror(errno));
                return -1;
        }

        else if(pid == 0)
        {
                printf("child process PID[%d] start running.My parent PID:%d\n",getpid(), getppid());
                return 0;
        }

        else
        {
                printf("parent process PID[%d] continue running and child process PID:%d\n",getpid(), pid);
                //wait(NULL);
                return 0;
        }
}

运行结果及分析:

parent process PID[41528] continue running and child process PID:41529
child process PID[41529] start running.My parent PID:1
/*这里我们可以看到,子进程在运行时打印出来的父进程的进程ID为:1
这是为什么呢?
如上面所说的一样,我们无法确定fork之后是子进程先运行(退出)还是父进程先
运行(退出),而在这里恰巧父进程在子进程退出之前退出了,这时候子进程就变
成了孤儿进程。当然每一个进程都应该有一个独一无二的父进程,init进程就是这样
的一个“慈父",Linux内核中所有的子进程在变成孤儿进程之后都会被init进程“领养”,
这也意味着孤儿进程的父进程最终会变成init进程(进程ID为:1)。
必要的时候可以使用wait或 waitpid函数让父进程等待子进程的结束并获取子进程的
返回状态。
以下是在父进程中加了wait()函数后的运行结果:
*/
parent process PID[42002] continue running.and child process PID:42003
child process PID[42003] start running.My parent PID:42002

2. wait()和waitpid()函数

上面可以看到用了wait()函数后,就解决了父进程在子进程退出之前退出,子进程进程会变成孤儿进程的问题。下面我们就来浅析一下wait()和waitpid()函数的用法。

pid_t wait(int *status)

  • 进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。
  • 参数:
    参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。但如果我们对这个子进程是如何死掉的毫不在意,只想把这个僵尸进程消灭掉,我们就可以设定这个参数为NULL,就象下面这样:
    pid = wait(NULL);
  • 返回值:
    如果成功,wait会返回被收集的子进程的进程ID。
    如果调用进程没有子进程,调用就会失败,此时wait返回-1,同时errno被置为ECHILD。

pid_t waitpid(pid_t pid,int *status,int options)

  • 从本质上讲,系统调用waitpid和wait的作用是完全相同的,但waitpid多出了两个可由用户控制的参数pid和options,从而为我们编程提供了另一种更灵活的方式。

  • 参数:(status同上)     
    pid:从参数的名字pid和类型pid_t中就可以看出,这里需要的是一个进程ID。但当pid取不同的值时,在这里有不同的意义。     
    pid>0时,只等待进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。
    pid=-1时,等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样。
    pid=0时,等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何理睬。
    pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。   
    options: options提供了一些额外的选项来控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED两个选项,这是两个常数,可以用"|"运算符把它们连接起来使用
    比如:
    ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);   
    如果我们不想使用它们,也可以把options设为0,如:   
    ret=waitpid(-1,NULL,0);     
    如果使用了WNOHANG参数调用waitpid,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。
    此处参考:http://blog.chinaunix.net/uid-25365622-id-3045460.html

3. vfork()函数与fork()函数的区别

两个函数的原型和头文件都是相同的。

pid_t vfork(void);

  • 功能:
    vfork() 函数和 fork() 函数一样都是在已有的进程中创建一个新的进程,但它们创建的子进程是有区别的。
  • 参数:
  • 返回值:
    成功:子进程中返回 0,父进程中返回子进程 ID。pid_t,为无符号整型。
    失败:返回 -1。

fork()和vfork()都是创建子进程,那么他们的区别是什么呢?

  1. fork(): 父子进程的执行次序不确定。
    vfork():保证子进程先运行,在它调用 exec(进程替换) 或 exit(退出进程)之后父进程才可能被调度运行。
  2. fork(): 子进程拷贝父进程的地址空间,子进程是父进程的一个复制品。
    vfork():子进程共享父进程的地址空间(准确来说,在调用 exec(进程替换) 或 exit(退出进程) 之前与父进程数据是共享的)

vfork() 保证子进程先运行,在它调用 exec(进程替换) 或 exit(退出进程)之后父进程才可能被调度运行。如果子进程没有调用 exec, exit, 程序则会导致死锁,程序是有问题的程序,将没有意义。

4. exec*()函数执行另外一个程序

exec系列函数(execl、execlp、execle、execv、execvp)

  • 头文件: #include <unistd.h>

  • 在上面的例子中,我们创建了一个子进程是让子进程继续执行父进程的文本段,但更多的情况下是让该进程去执行另外一个程序。这时我们会在fork()之后紧接着调用exec*()系列的函数来让子进程去执行另外一个程序。其原型为:

    int execl(const char *path, const char *arg, ...);
    int execlp(const char *file, const char *arg, ...);
    int execle(const char *path, const char *arg, ..., char * const envp[]);
    int execv(const char *path, char *const argv[]);
    int execvp(const char *file, char *const argv[]);
    
  • 参数:
    path参数表示你要启动程序的名称包括路径名
    arg参数表示启动程序所带的参数,一般第一个参数为要执行命令名,不是带路径且arg必须以NULL结束

  • 返回值:成功返回0,失败返回-1

以上exec系列函数区别:

1,带 l 的exec函数:execl,execlp,execle,表示后边的参数以可变参数的形式给出且都以一个空指针结束。
2,带 p 的exec函数:execlp,execvp,表示第一个参数path不用输入完整路径,只有给出命令名即可,它会在环境变量PATH当中查找命令。
3,不带 l 的exec函数:execv,execvp表示命令所需的参数以char *arg[]形式给出且arg最后一个元素必须是NULL。
4,带 e 的exec函数:execle表示,将环境变量传递给需要替换的进程。

在这么多的函数调用中,我们选择一个实现即可,因为execl()函数的参数相对简单些所以使用它要多些,接下来以一个程序实例来演示它的使用。首先看一下ifconfig ens33命令的输出:

ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.22.135  netmask 255.255.255.0  broadcast 192.168.22.255
        inet6 fe80::20c:29ff:fe74:da3e  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:74:da:3e  txqueuelen 1000  (Ethernet)
        RX packets 519053  bytes 512527694 (512.5 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 212703  bytes 19109317 (19.1 MB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        

我们知道,在Linux下可以使用命令ifconfig ens33来获取网卡的IP地址,但如果我们想在C程序代码里获取IP地址又该如何实现呢?其实ifconfig命令本身是一个程序,这样我们可以在程序里创建一个子进程来执行这个程序即可。另外一个问题是该命令执行的结果会打印到标注输出(默认是屏幕)上,那我们C程不可能像人眼一样在屏幕上获取IP地址。对于这个问题我们可以在子进程里将标准输出重定向到文件里,这样命令的打印信息会输出到该文件中。之后父进程就可以从该文件中读出相应的内容并作相应的字符串解析,就可以获取到IP地址了。

示例代码如下:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <ctype.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define TMP_FILE "/tmp/.ifconfig.log"

int main(int argc, char *argv[])
{
        int             fd = -1;
        int             rv = -1;
        FILE            *fp;
        char            buf[1024];
        char            ipaddr[32];
        char            *ptr;
        char            *ip_head;
        char            *ip_tail;
        pid_t           pid;

        fd = open(TMP_FILE, O_RDWR|O_CREAT|O_TRUNC, 0666);
        if(fd < 0)
        {
                printf("open fail failure: %s\n", strerror(errno));
                return -1;
        }

        pid = fork();

        if(pid < 0)
        {
                printf("fork() fail...\n");
                return -2;
        }

        else if(pid == 0)
        {
                printf("child process strat excute ifconfig program\n");
                dup2(fd, STDOUT_FILENO);
                execl("/sbin/ifconfig", "ifconfig", "ens33", NULL);
                //后面不会执行
                printf("If you see this tip, it means execl() error...\n");
        }

        else
        {
                sleep(3);
        }
        //子进程调用execl()函数会丢掉父进程的文本段,所以子进程不会执行到这里
        memset(buf, 0, sizeof(buf));
        lseek(fd, 0, SEEK_SET);
        rv = read(fd, buf, sizeof(buf));
        if(rv < 0)
        {
                printf("read file failure...\n");
                return -4;
        }
        printf("get buf:\n%s\n", buf);
        printf("read %d bytes data from ifconfig program.\n", rv);

        //fdopen()函数可以将文件描述符fd转成文件流fp
        fp = fdopen(fd, "r");
        //设置文件偏移量到文件头
        fseek(fp, 0, SEEK_SET);
        while(fgets(buf, sizeof(buf), fp) != NULL)
        {
                if(strstr(buf, "netmask"))
                {
                        if(strstr(buf, "inet") != NULL)
                        {
                                ptr = strstr(buf, "inet");
                        }

                        ptr += strlen("inet");
        //isblank()函数用来检测一个字符是否是一个空白符
        //参数为要检测的字符的ASCII值
        //返回值为真表示为空白符,返回值为假(0)表示不是空白符
                        while(isblank(*ptr))
                        {
                                ptr++;
                        }
                        ip_head = ptr;

                        while(!isblank(*ptr))
                        {
                                ptr++;
                        }
                        ip_tail = ptr;
                        memset(ipaddr, 0, sizeof(ipaddr));
                        memcpy(ipaddr, ip_head, ip_tail-ip_head);
                }
        }

        printf("get IP address: %s\n", ipaddr);
        fclose(fp);
        unlink(TMP_FILE);
}

5. system()和popen()函数

1. system()函数

如果我们在程序中,想执行另外一个Linux命令时,可以调用fork()然后再exec执行相应的命令即可,但这样相对比较麻烦。Linux系统提供了一个system()库函数,该库函数可以快速创建一个进程来执行相应的命令。

int system( const char *command);

譬如,我们想执行ifconfig命令,下面是一个简单的例子:

#include <stdio.h>
#include <stdlib.h>

int main()
{
        system("ifconfig ens33 | grep netmask");
        return 0;
}

运行结果:

        inet 192.168.22.135  netmask 255.255.255.0  broadcast 192.168.22.255

命令行输入的结果:
在这里插入图片描述

2. popen()函数

对于之前我们使用fork()+execl()函数来执行ifconfig命令,并将该命令执行的结果写入到文件后再来读取的实现,这个过程相对比较麻烦,另外涉及到了创建文件和读取文件的过程。其实也有另个函数popen()可以执行一条命令,并返回一个基于管道(pipe)的文件流,这样我们可以从该文件流中—行样解析了。

#include <stdio.h>
函数原型:

FILE *popen(const char *command, const char *type);		//使用前要申明
int pclose(FILE *stream);		//用完要记得关闭

函数说明:
(1)popen()会调用fork()产生子进程,然后从子进程中调用/bin/sh -c来执行参数command的指令。
(2)参数type可使用“r”代表读取,“w”代表写入。依照此type值,popen()会建立管道连到子进程的标准输出设备或标准输入设备,然后返回一个文件指针。随后进程便可利用此文件指针来读取子进程的输出设备或是写入到子进程的标准输入设备中。
(3)此外,所有使用文件指针(FILE*)操作的函数也都可以使用,除了fclose()以外。
(4)如果 type 为 r,那么调用进程读进 command 的标准输出。
如果 type 为 w,那么调用进程写到 command 的标准输入。
返回值:若成功则返回文件指针,否则返回NULL,错误原因存于errno中。

相应的代码如下:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <ctype.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

//定义一个获取IP地址的函数
//第一个参数为网卡名称,第二个为存放IP的一个地址,最后为该地址的大小
int get_ipaddr(char *interface, char *ipaddr, int ip_size);

int main(int argc, char *argv[])
{
        char            ipaddr[16];
        char            *interface = "ens33";		//网卡名称

        memset(ipaddr, 0, sizeof(ipaddr));
        if(get_ipaddr(interface, ipaddr, sizeof(ipaddr)) < 0)		
        {
                printf("ERROR: get %s IP address failure.\n", interface);
                return -1;
        }
        printf("get %s IP address: %s\n", interface, ipaddr);
}

int get_ipaddr(char *interface, char *ipaddr, int ip_size)			
{
        char            buf[1024];
        FILE            *fp;
        char            *ptr;
        int             rv;
        char            *ip_start;
        char            *ip_end;

        if(!interface | !ipaddr | (ip_size < 16))		//判断参数是否合法
        {
                printf("Invalid input arguments.\n");
                return -1;
        }

        memset(buf, 0, sizeof(buf));
        snprintf(buf, sizeof(buf), "ifconfig %s", interface);	//格式化字符串复制到buf中(可查看指定网卡)
        if((fp = popen(buf, "r")) == NULL)
        {
                printf("popen() to excute command '%s' failure: %s\n", buf, strerror(errno));
                return -1;
        }
        rv = -1;
        while(fgets(buf, sizeof(buf), fp))
        {
                if(strstr(buf, "netmask"))
                {
                        if(strstr(buf, "inet") != NULL)
                        {
                                ptr = strstr(buf, "inet");
                        }

                        ptr += strlen("inet");
                        while(isblank(*ptr))
                        {
                                ptr++;
                        }
                        ip_start = ptr;

                        while(!isblank(*ptr))
                        {
                                ptr++;
                        }
                        ip_end = ptr;
                        memset(ipaddr, 0, sizeof(ipaddr));
                        memcpy(ipaddr, ip_start, ip_end-ip_start);
                        rv = 0;
                        break;
                }
        }
        pclose(fp);
        return rv;
}

运行结果:

get ens33 IP address: 192.168.22.135

6. 多进程改写服务器程序

在这里插入图片描述
示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <dirent.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>

#define PORT    7077
#define BACKLOG 100
#define BACK    "Receive your ask!"

int main(int argc, char *argv[])
{
        int                     listen_fd = -1;
        int                     client_fd = -1;
        int                     fd = -1;
        int                     rv = -1;
        struct  sockaddr_in     serv_addr;
        struct  sockaddr_in     cli_addr;
        socklen_t               addrlen = 64;
        char                    buf[1024];
        char                    buf1[64];
        int                     on = 1;
        pid_t                   pid;

        listen_fd = socket(AF_INET, SOCK_STREAM, 0);
        if(listen_fd < 0)
        {
                printf("craete socket failure:%s\n",strerror(errno));
                return -1;
        }

        printf("create socket[fd:%d] scuess\n",listen_fd);

        setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
        memset(&serv_addr, 0, sizeof(serv_addr));
        serv_addr.sin_family = AF_INET;
        serv_addr.sin_port   = htons(PORT);
        serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if(bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
        {
                printf("bind port failure:%s\n", strerror(errno));
                return -1;
        }

        if(listen(listen_fd, BACKLOG) < 0)
        {
                printf("listen error:%s\n", strerror(errno));
                return -1;
        }

        while(1)
        {
                printf("\nwaiting new client connect...\n");
                client_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &addrlen);
                if(client_fd < 0)
                {
                        printf("accept new socket failure:%s\n", strerror(errno));
                        return -1;
                }
                printf("Accept new socket[%s:%d] success!\n",inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));

                pid = fork();
                if(pid < 0)
                {
                        printf("fork() failure: %s\n", strerror(errno));
                        return -1;
                }

                else if(pid == 0) 	 //子进程完成与接受到的客户端的交接
                {
                        close(listen_fd);
                        memset(buf, 0, sizeof(buf));
                        if((rv = read(client_fd, buf, sizeof(buf))) < 0)
                        {
                                printf("read data from client socket[%s] failure:\n",strerror(errno));
                                close(client_fd);
                                continue;
                        }
                        else if(rv == 0)
                        {
                                printf("client socket[%d] disconnected:\n",client_fd);
                                close(client_fd);
                                continue;
                        }

                        printf("read %d bytes data from client:%s\n", rv, buf);

                        memset(buf1, 0, sizeof(buf1));
                        strncpy(buf1, BACK, strlen(BACK));
                        if(write(client_fd, buf1, strlen(buf1)) < 0 )
                        {
                                printf("write failure:%s\n",strerror(errno));
                                return -1;
                        }

                        close(client_fd);
                        break;
                }
                else            //父进程
                {
                        close(client_fd);
                        //这里我们断开客户端的连接,是因为子进程会代替父进程去完成与客户端的交接,而父进程要去接收新的客户端的连接了
                        sleep(1);	
                }
        }
        close(listen_fd);
}

运行结果:

create socket[fd:3] scuess

waiting new client connect...
Accept new socket[223.104.20.243:19571] success!

waiting new client connect...
Accept new socket[223.104.20.243:19572] success!

waiting new client connect...
Accept new socket[223.104.20.243:19573] success!

waiting new client connect...
read 21 bytes data from client:Enter data to send...
read 6 bytes data from client:hahaha
read 6 bytes data from client:xixixi

这里我们就可以看到,服务器已经可以接受多个客户端同时连接了,那么服务器到底能够接受多少客户端同时连接呢?这是我们接下来要讨论的问题了。

7. 系统限制

在上面我们使用多进程可以实现多个客户端的并发,那是不是一个服务器就可以给无限多个客户端提供服务呢?其实不然!在Linux下每种资源都有相关的软硬限制,譬如单个用户最多能创建的子进程个数有限制,同样一个进程最多能打开的文件描述符也有相应的限制值,这些限制会限制服务器能够提供并发访问的客户端的数量。在Linux系统下,我们可以使用下面两个函数来获取或设置这些限制:

#include <sys/resource. h>

int getrlimit(int resource,struct rlimit *rlim);
int setrlimit(int resource,const struct rlimit *rlim);

参数 resource说明:

  • RLIMIT_AS 进程的最大虚内存空间,字节为单位。
  • RLIMIT_CORE 内核转存文件的最大长度。
  • RLIMIT_CPU最大允许的CPU使用时间,秒为单位。当进程达到软限制,内核将给其发送SIGXCPU信号,这一信号的默认行为是终止进程的执行。
  • RLIMIT_DATA 进程数据段的最大值。
  • RLIMIT_FSIZE 进程可建立的文件的最大长度。如果进程试图超出这一限制时,核心会给其发送
  • SIGXFSZ信号,默认情况下将终止进程的执行。
  • RLIMIT_LOCKS 进程可建立的锁和租赁的最大值。
  • RLIMIT_MEMLOCK 进程可锁定在内存中的最大数据量,字节为单位。
  • RLIMIT_MSGQUEUE 进程可为POSIX消息队列分配的最大字节数。
  • RLIMIT_NICE 进程可通过setpriority() 或 nice()调用设置的最大完美值。
  • RLIMIT_NOFILE 指定比进程可打开的最大文件描述词大一的值,超出此值,将会产生EMFILE错误。
  • RLIMIT_NPROC 用户可拥有的最大进程数。
  • RLIMIT_RTPRIO 进程可通过sched_setscheduler 和 sched_setparam设置的最大实时优先级。
  • RLIMIT_SIGPENDING 用户可拥有的最大挂起信号数。
  • RLIMIT_STACK 最大的进程堆栈,以字节为单位。

描述资源软硬限制的结构体:

struct rlimit
{
	rlim_t rlim_cur;  /* 软件限制 */
	rlim_t rlim_max;  /* 硬件限制(硬件限制相当于软件限制的上限) */
};

示例代码:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/resource.h>

void print_limits(char *name, int resource)
{
        struct rlimit limit;
        if(getrlimit(resource, &limit) != 0)
        {
                printf("getrlimit for %s failure: %s\n", name, strerror(errno));
                return;
        }

        printf("%-15s ", name);
        
        if(limit.rlim_cur == RLIM_INFINITY)			//常量RLIM_INFINITY指定了一个无限量的限制
        {
                printf("(infinite)     ");
        }
        else
        {
                printf("%-15ld", limit.rlim_cur);
        }

        if(limit.rlim_max == RLIM_INFINITY)
        {
                printf("(infinite)     ");
        }
        else
        {
                printf("%-15ld", limit.rlim_max);
        }
        printf("\n");
}

int main(void)
{
        struct rlimit limit;
        print_limits("RLIMIT_NPROC", RLIMIT_NPROC);		//每个实际用户ID可拥有的最大子进程数
        print_limits("RLIMIT_DATA", RLIMIT_DATA);			 //数据段的最大字节长度
        print_limits("RLIMIT_STACK", RLIMIT_STACK);		 //栈的最大字节长度
        print_limits("RLIMIT_NOFILE", RLIMIT_NOFILE);			//每个进程能打开的最大文件数

        printf("\nAfter set RLIMIT_NOFILE: \n");
        getrlimit(RLIMIT_NOFILE, &limit );
        limit.rlim_cur = limit.rlim_max;
        setrlimit(RLIMIT_NOFILE, &limit );

        print_limits("RLIMIT_NOFILE", RLIMIT_NOFILE);

        return 0;
}

运行结果:

RLIMIT_NPROC    7615           7615
RLIMIT_DATA     (infinite)     (infinite)
RLIMIT_STACK    8388608        (infinite)
RLIMIT_NOFILE   1024           1048576

After set RLIMIT_NOFILE:
RLIMIT_NOFILE   1048576        1048576

由上所知,一个服务器程序抛开硬件限制以外,还会受到Linux系统的资源限制。所以,如果我们想要增加Linux服务器并发访问的客户端数量,可以在服务器程序里调用setrlimit()函数来修改这些限制。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值