Linux系统编程(五)并发(信号、线程)

目录

一、信号

1.1 信号的概念

1.2 signal()

1.3 可重入函数

1.4 信号的响应过程(重点)

1.5 信号相关函数(kill、raise、alarm、pause、abort)

1.6 信号集

二、线程  

2.1 线程的概念

2.2 线程的创建、终止,栈的清理

2.3 线程同步(互斥量、条件变量、信号量、读写锁)

2.4 线程属性,线程同步的属性

2.5 openmp 线程标准(相对于 posix 线程标准)


一、信号

1.1 信号的概念

信号是软件层面的中断。信号的响应依赖于中断。信号分为标准信号和实时信号。

kill -l 可以查看系统中的信号:

core 文件是程序出错的现场,可以使用 gdb 对 core 文件进行调试。 

1.2 signal()

signal(2) 可以为特定的信号 signum 注册一个新的处理函数 handler,并且返回之前的处理函数。当出现特定的信号 signum 就会调用 handler。假如 handler 为 SIGIGN,则信号会被忽视;若 handler 为 SIGDFL,则会执行默认的处理函数。

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

该函数实际的样子:

例子

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

void sig_handler(int signum)
{
  write(1, "1", 1);
}

int main()
{
  // signal(SIGINT, SIGIGN);
  signal(SIGINT, sig_handler);

  for (int i = 0; i < 10; i++) {
    write(1, "*", 1);
    sleep(1);
  }
  exit(0);
}

 ctrl + c 可以发出 SIGINT 信号,所以每次使用 ctrl + c 都会调用一次信号处理函数 sig_handler:

重点:信号会打断阻塞的系统调用!!

如果 ctrl + c 按的很快的话可以看到 sleep 系统调用会被打断。 比如 open 和 read 系统调用中的两个错误码:

所以在前面例子中系统调用失败可能是由于信号导致的假错误,这时候我们可以重新进行一次系统调用。 

不能随意地在信号处理函数中往外跳。

1.3 可重入函数

信号的不可靠,比如说第一次调用还没结束,第二次调用就开始了(连续两个相同信号到来)。可以使用可重入函数解决,可重入函数在第一次调用还没结束时发生第二次调用不会出错。

所有的系统调用都是可重入的,部分库函数是可重入的

memcpy() 的两个内存地址空间不能重叠,而 memmove() 可以。

1.4 信号的响应过程(重点)

信号从收到到响应有一个不可避免的延迟。在从 kernel 返回到 user 态的时候才会查看 mask 和 pending 位图的按位与,然后响应信号。

如何忽略掉一个信号的?(mask 清 0)

标准信号为什么要丢失(多次置 pending 为 1,只响应一次)。

在收到多个标准信号时,标准信号的响应没有严格的顺序。

在响应信号的时候,mask 置 0,防止重入。

1.5 信号相关函数(kill、raise、alarm、pause、abort)

1. kill(2) 系统调用可以发送任意信号给任意进程或进程组。

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

pid 有如下四种情况: 

  • 当 pid 为正数时,sig 信号被发送给 pid 指定的进程。
  • 当 pid 为 0 时,sig 信号会被发送给调用进程的进程组内的所有进程。
  • 当 pid 为 -1 时,sig 信号会被发送给当前进程有权限发送信号的每一个进程,除了 init 进程(1 号进程)。
  • 当 pid 小于 -1 时,sig 信号会被发送给 pgid 为 -pid 的进程组内的所有进程。

sig 参数为 0 时,不发送任何信号,可以用于检测进程和进程组是否存在(错误码为 ESRCH)。

2. raise(3) 可以给当前进程或线程发送信号。

#include <signal.h>

int raise(int sig);

在单线程的程序中 raise() 等效于: 

kill(getpid(), sig);

在多线程的程序中 raise() 等效于:

pthread_kill(pthread_self(), sig);

3. alarm(2) 系统调用可以定时发送一个 SIGALRM 信号(注意不要在一个程序中多次使用 alarm ,多次使用时,只有最后一个 alarm 生效)。

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

当 seconds 为 0 时,所有等待的 alarm 都被取消。

alarm 可以用于实现流量控制,有如下两种方式:

  • 漏桶,就算海量的数据到来,还是以固定的速率处理数据,但没有数据的时候会死等。
  • 令牌桶, 没有数据的时候会攒令牌,当数据到来的时候可以根据令牌数量处理更多的数据。

例子,mytbf,可以使用令牌桶来读取文件内容:

/* main.c */

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>
#include "mytbf.h"

#define CPS 10
#define BUFSIZE 1024
#define BURST 100

int main(int argc, char **argv)
{
  int sfd, dfd = 1;
  char buf[BUFSIZE];
  int len, ret, pos, token_nums;
  mytbf_t *tbf;

  if (argc < 2) {
    fprintf(stderr, "Usage...\n");
    exit(1);
  }

  tbf = mytbf_init(CPS, BURST);
  if (tbf == NULL) {
    fprintf(stderr, "tbf is NULL\n");
    exit(1);
  }

  do {
    if ((sfd = open(argv[1], O_RDONLY)) < 0) {
      if (errno != EINTR) {
        perror("open()");
        exit(1);
      }
    }
  } while (sfd < 0);

  while (1) {
    token_nums = mytbf_fetchtoken(tbf, BUFSIZE);

    while ((len = read(sfd, buf, token_nums)) < 0) {
      if (errno == EINTR)
        continue;
      perror("read()");
      break;
    }
    if (len == 0)
      break;

    // return token which did not used
    if (token_nums - len > 0) {
      mytbf_returntoken(tbf, token_nums - len);
    }

    pos = 0;
    while (len > 0) {
      ret = write(dfd, buf + pos, len);
      if (ret < 0) {
        if (errno == EINTR)
          continue;
        perror("write()");
        exit(1);
      }
      pos += ret;
      len -= ret;
    }
  }

  close(sfd);
  mytbf_destroy(tbf);

  exit(0);
}
          
/* mytbf.h */

#ifndef MYTBF_H__
#define MYTBF_H__

#define MYTBF_MAX  1024
typedef void mytbf_t;

mytbf_t *mytbf_init(int cps, int burst);

int mytbf_fetchtoken(mytbf_t *, int );

int mytbf_returntoken(mytbf_t *, int );

int mytbf_destroy(mytbf_t *);

#endif
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include "mytbf.h"

typedef void (*sighandler_t)(int);

static struct mytbf_st *job[MYTBF_MAX];
static int inited = 0;
static sighandler_t alrm_handler_save;

struct mytbf_st
{
  int cps; // number of characters per second
  int burst; // max number of token
  int token; // number of token to send characters
  int pos;
};

static int get_free_pos(void)
{
  for (int i = 0; i < MYTBF_MAX; i++) {
    if (job[i] == NULL) {
      return i;
    }
  }
  return -1;
}

static void alrm_handler(int s)
{
  alarm(1);
  for (int i = 0; i < MYTBF_MAX; i++) {
    if(job[i] != NULL)
    {
      job[i]->token += job[i]->cps;
      if(job[i]->token > job[i]->burst) {
        job[i]->token = job[i]->burst;
      }
    }
  }
}

static void module_unload(void)
{
  signal(SIGALRM, alrm_handler_save);
  // close the registered alarm
  alarm(0);

  for (int i = 0; i < MYTBF_MAX; i++) {
    free(job[i]);
  }
}

static void module_load(void)
{
  alrm_handler_save = signal(SIGALRM, alrm_handler);
  alarm(1);

  // register a function to be called at normal process termination
  atexit(module_unload);
}

mytbf_t *mytbf_init(int cps, int burst)
{
  int pos;
  pos = get_free_pos();
  if (pos < 0) {
    return NULL;
  }

  if (!inited) {
    module_load();
    inited = 1;
  }

  struct mytbf_st *me = (struct mytbf_st *)malloc(sizeof(struct mytbf_st));
  if (me == NULL) {
    return NULL;
  }
  me->token = 0;
  me->cps = cps;
  me->burst = burst;
  me->pos = pos;
  job[pos] = me;

  return me;
}

static int min(int a, int b)
{
  return (a > b) ? b : a;
}

int mytbf_fetchtoken(mytbf_t *tbf, int nums)
{
  int n;
  struct mytbf_st *me = (struct mytbf_st *)tbf;
  if (nums <= 0) {
    return -1;
  }

  while (me->token <= 0) {
    // wait for signal
    pause();
  }

  n = min(me->token, nums);
  me->token -= n;
  return n;
}

int mytbf_returntoken(mytbf_t *tbf, int nums)
{
  struct mytbf_st *me = (struct mytbf_st *)tbf;
  if (nums <= 0) {
    return -1;
  }

  me->token += nums;
  if (me->token > me->burst) {
    me->token = me->burst;
  }

  return nums;
}

int mytbf_destroy(mytbf_t *tbf)
{
  struct mytbf_st *me = (struct mytbf_st *)tbf;
  job[me->pos] = NULL;
  free(tbf);
  return 0;
}

sig_atomic_t 修饰符可以保证被修饰的变量操作一定是原子的。 

4. pause(2) 系统调用使调用进程睡眠,等待信号到来唤醒进程。

#include <unistd.h>

int pause(void);

5. abort(3) 会给当前进程发送一个 SIGABRT 信号,会结束当前进程并且产生一个 coredump 文件。

#include <stdlib.h>

void abort(void);

扩展,sigsuspend(),sigaction(),setitimer()

1.6 信号集

信号集类型:sigset_t。

相关函数:

  • sigemptyset();
  • sigfillset();
  • sigaddset();
  • sigdelset();

信号屏蔽字相关函数:

  • sigprocmask(2),可以将信号设置为阻塞状态,即不予响应,并且可以将之前的信号集 sigset_t 返回。
#include <signal.h>

/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • sigsuspend(2) 系统调用可以原子地设置 mask,然后 pause() 等待信号的到来,最后 恢复原来的 mask。
#include <signal.h>

int sigsuspend(const sigset_t *mask);

sigaction(2) 系统调用功能和 signal() 类似,可以设置或改变信号的处理函数,但是该系统调用可以在设置信号屏蔽字,以在信号处理函数执行的时候屏蔽其他需要屏蔽的信号。

#include <signal.h>

int sigaction(int signum, const struct sigaction *act,
              struct sigaction *oldact);

实时信号不会丢失,发送几次响应几次。

二、线程  

2.1 线程的概念

线程有许多不同的标准,比如 posix 线程是一套标准,而不是实现。

线程标识:pthread_t

进程相当于容器,用来承载线程(相同的进程号 pid,但轻量级进程号 lwp 不一样,并且会占用进程号):

pthread_equal(3) 能够比较线程的 id,pthread_self(3) 可以返回当前线程 id。

线程相关的函数在编译连接的时候大多要加上 -pthread(CFLAGS 和 LDFLAGS)。

2.2 线程的创建、终止,栈的清理

1. pthread_create(3) 可以创建一个新线程,执行成功时返回 0。

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

start_routine 指向线程执行的函数,arg 为该线程函数传入的唯一参数;attr 参数可以指定线程的一些属性;当线程创建成功时,其线程 id 会被放到 thread 中返回。

一个线程只有在如下几种情况发生时才会终止:

  • pthread_exit(3);
  • 从 start_routine() 返回,返回值就是线程的退出码;
  • 线程可以被同一进程中的其他线程取消,pthread_cancel(3);
  • 有线程调用了 exit(3),或者主线程从 main() 返回,此时所有线程都会终止; 

线程的调度取决于调度器策略。 

2. 线程的收尸,pthread_join(3),相当于进程的 wait()。此函数会等待 thread 指定线程的终止,如果线程已经终止则立即返回。

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

3. 栈的清理,pthread_cleanup_push() 和 pthread_cleanup_pop(),相当于钩子函数,当线程被取消时会自动调用,要成对使用。

#include <pthread.h>

void pthread_cleanup_push(void (*routine)(void *),
                                 void *arg);
void pthread_cleanup_pop(int execute);

4. 线程的取消,pthread_cancel(3),可以取消线程使其终止,然后再为其收尸。

#include <pthread.h>

int pthread_cancel(pthread_t thread);

取消有 2 种状态:允许和不允许;

不允许又分为:异步 cancel,推迟 cancel(默认)-> 推迟至 cancel 点响应;

cancel 点:POSIX 定义的 cancel 点,都是可能引发阻塞的系统调用。

5. 线程分离,pthread_detach(3),不需要为分离出去的线程收尸。

例子,primes_thread.c,利用线程来计算质数:

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

#define MAX 100
#define THREADNUM 100

void *thread_func(void *arg)
{
  int i = *((int *)arg);
  int flag = 1;
  for (int j = 2; j <= i / 2; j++) {
    if (i % j == 0) {
      flag = 0;
      break;
    }
  }

  if (flag) {
    printf("%d\n", i);
  }

  pthread_exit(arg);
}

int main()
{
  pthread_t thread_ids[THREADNUM] = {0};
  int err;
  void *retvalue;
  for (int i = 2; i < MAX; i++) {
    int *ip = (int *)malloc(sizeof(int));
    *ip = i;
    err = pthread_create(thread_ids + i, NULL, thread_func, ip);
    if (err) {
      fprintf(stderr, "pthread_create");
      exit(1);
    }
  }

  for (int i = 0; i < THREADNUM; i++) {
    if (thread_ids[i] != 0) {
      pthread_join(thread_ids[i], &retvalue);
      free(retvalue);
    }
  }
  exit(0);
}

2.3 线程同步(互斥量、条件变量、信号量、读写锁)

线程竞争例子,thread_contention.c,使用 10 个线程对 indexs 全局变量进行自增 10000000 次:

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

#define THREADNUM 10

int indexs = 0;

void *thread_func(void *arg)
{
  for (int i = 0; i < 10000000; i++) {
    indexs++;
  }
  pthread_exit(NULL);
}

int main()
{
  pthread_t thread_ids[THREADNUM] = {0};
  int err;
  for (int i = 0; i < THREADNUM; i++) {
    err = pthread_create(thread_ids + i, NULL, thread_func, NULL);
    if (err) {
      fprintf(stderr, "pthread_create");
      exit(1);
    }
  }

  for (int i = 0; i < THREADNUM; i++) {
    if (thread_ids[i] != 0) {
      pthread_join(thread_ids[i], NULL);
    }
  }

  printf("%d\n", indexs);
  exit(0);
}

理想中的结果应该是 10 x 10000000,但是运行程序后结果不是这个,并且每次运行都不一样,这是因为 indexs++ 并非原子的,而想让程序避免冲突就需要用到线程同步。

线程同步的一种方法是使用互斥量(pthread_mutex_t)。

互斥量的各个函数:

#include <pthread.h>

/* 动态初始化互斥量 */
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
/* 静态初始化互斥量 */
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

/* 给互斥量上锁,会阻塞等待 */
int pthread_mutex_lock(pthread_mutex_t *mutex);

/* 尝试给互斥量上锁,不会阻塞等待,上不了立即返回 */
int pthread_mutex_trylock(pthread_mutex_t *mutex);

/* 给互斥量解锁 */
int pthread_mutex_unlock(pthread_mutex_t *mutex);

/* 销毁互斥量 */
int pthread_mutex_destroy(pthread_mutex_t *mutex);

静态初始化互斥量是使用的默认属性。lock 和 unlock 中间的区域被称为临界区。

下面我们使用互斥量来解决上面例子出现的问题,在 indexs++ 的执行在临界区中使其变为原子的操作:

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

#define THREADNUM 10

static pthread_mutex_t mut = PTHREAD_MUTEX_INITIALIZER;
int indexs = 0;

void *thread_func(void *arg)
{
  for (int i = 0; i < 10000000; i++) {
    pthread_mutex_lock(&mut);
    indexs++;
    pthread_mutex_unlock(&mut);
  }
  pthread_exit(NULL);
}

int main()
{
  pthread_t thread_ids[THREADNUM] = {0};
  int err;
  for (int i = 0; i < THREADNUM; i++) {
    err = pthread_create(thread_ids + i, NULL, thread_func, NULL);
    if (err) {
      fprintf(stderr, "pthread_create");
      exit(1);
    }
  }

  for (int i = 0; i < THREADNUM; i++) {
    if (thread_ids[i] != 0) {
      pthread_join(thread_ids[i], NULL);
    }
  }

  printf("%d\n", indexs);
  pthread_mutex_destroy(&mut);
  exit(0);
}

再次运行结果就是正确的了:

当然,想上面这种加锁方式非常的低效,因为每次执行 indexs++ 都要请求锁 -> 释放锁,并且 indexs++ 执行地非常频繁,这导致线程间的锁的竞争非常强烈(一个线程拿到了锁,其他线程要等待),后面在锁的细粒度会涉及到这部分内容。

另外一种同步方法是使用条件变量(pthread_cond_t)

#include <pthread.h>

/* 静态初始化 */
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

/* 动态初始化 */
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);

/* 叫醒任意一个因为条件变量而阻塞的线程 */
int pthread_cond_signal(pthread_cond_t *cond);

/* 叫醒所有因为条件变量而阻塞的线程 */
int pthread_cond_broadcast(pthread_cond_t *cond);

/* 等待条件变量 */
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

/* 有限时间地等待条件变量 */
int pthread_cond_timedwait(pthread_cond_t *cond,  pthread_mutex_t  *mutex,  const  struct
       timespec *abstime);

/* 销毁条件变量 */
int pthread_cond_destroy(pthread_cond_t *cond);

pthread_cond_wait() 函数会先释放互斥锁 mutex,然后睡眠等待,直到被 pthread_cond_signal() 或者 pthread_cond_broadcast() 唤醒,然后抢锁,查看条件变量。

例子,primes_thread_pool_cond.c,使用条件变量与多线程实现质数计算: 

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

#define MAX 100
#define THREADNUM 4

static int num = 0; // the num that give to thread to compute
static pthread_mutex_t mut_num = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_num = PTHREAD_COND_INITIALIZER;

void *thread_func(void *arg)
{
  int i;
  while (1) {
    pthread_mutex_lock(&mut_num);

    while (num == 0) {
      pthread_cond_wait(&cond_num, &mut_num);
    }
    // all work has done, exit the thread
    if (num == -1) {
      pthread_mutex_unlock(&mut_num);
      pthread_exit(arg);
    }

    i = num;
    num = 0;

    pthread_mutex_unlock(&mut_num);

    // rouse main thread
    pthread_cond_broadcast(&cond_num);

    int flag = 1;
    for (int j = 2; j <= i / 2; j++) {
      if (i % j == 0) {
        flag = 0;
        break;
      }
    }
    if (flag) {
      printf("thread%d: %d\n", *(int*)arg, i);
    }
  }

  fprintf(stderr, "error\n");
  exit(1);
}

int main()
{
  pthread_t thread_ids[THREADNUM] = {0};
  int err;
  void *retvalue;
  for (int i = 0; i < THREADNUM; i++) {
    int *ip = (int *)malloc(sizeof(int));
    *ip = i;
    err = pthread_create(thread_ids + i, NULL, thread_func, ip);
    if (err) {
      fprintf(stderr, "pthread_create");
      exit(1);
    }
  }

  // assign compute task to thread
  for (int i = 2; i < MAX; i++) {
    pthread_mutex_lock(&mut_num);
    while (num != 0) {
        pthread_cond_wait(&cond_num, &mut_num);
    }
    num = i;
    pthread_mutex_unlock(&mut_num);

    pthread_cond_signal(&cond_num);
  }

  // set num to -1, means all work has done
  pthread_mutex_lock(&mut_num);
  while (num != 0) {
    pthread_cond_wait(&cond_num, &mut_num);
  }
  num = -1;
  pthread_mutex_unlock(&mut_num);

  for (int i = 0; i < THREADNUM; i++) {
    if (thread_ids[i] != 0) {
      pthread_join(thread_ids[i], &retvalue);
      free(retvalue);
    }
  }

  pthread_mutex_destroy(&mut_num);
  pthread_cond_destroy(&cond_num);
  exit(0);
}

但是条件变量只有 0 和 1,而信号量可以是任意数量,下面是使用互斥量和条件变量实现的信号量机制:

/* 信号量实现,可以实现某些固定的资源数量 */

#include <stdio.h>
#include <stdlib.h>
#include "mysem.h"
#include <pthread.h>

struct mysem_st
{
  int value;
  pthread_mute_t mute;
  pthread_cond_t cond;
};

mysem_t *mysem_init(int initval)
{
  struct mysem_st *me = (struct mysem_st *)malloc(sizeof(*me));
  if (me == NULL) {
    return NULL;
  }

  me->value = initval;
  pthread_mute_init(&me->mute, NULL);
  pthread_cond_init(&me->cond, NULL);

  return me;
}

int mysem_add(mysem_t *sem, int num)
{
  struct mysem_st *me = (struct mysem_st *)sem;
  pthread_mute_lock(&me->mute);
  me->value += num;
  pthread_mute_unlock(&me->mute);
  pthread_cond_broadcast(&me->cond);

  return 0;
}

int mysem_sub(mysem_t *sem, int num)
{
  struct mysem_st *me = (struct mysem_st *)sem;
  pthread_mute_lock(&me->mute);
  while (me->value < num) {
    pthread_cond_wait(&me->cond, &me->mute);
  }
  me->value -= num;
  pthread_mute_unlock(&me->mute);
}

int mysem_destroy(mysem_t *sem)
{
  if (sem == NULL) {
    return -1;
  }
  struct mysem_st *me = (struct mysem_st *)sem;
  pthread_mute_destroy(&me->mute);
  pthread_cond_destroy(&me->cond);
  free(me);

  return 0;
}

读写锁: 读锁 -> 共享锁,写锁 -> 互斥锁。

2.4 线程属性,线程同步的属性

线程属性的标识符为:pthread_attr_t

相关的函数有:

#include <pthread.h>

/* 线程属性结构体初始化 */
int pthread_attr_init(pthread_attr_t *attr);

/* 线程属性结构体销毁 */
int pthread_attr_destroy(pthread_attr_t *attr);

除此之外,还有很多,比如 pthread_attr_setstack(3) 可以设置线程的栈大小:

 

线程间通信要比进程间通信快,因为线程共享同一个进程的地址空间。而进程间通信需要借助特殊的机制,比如管道(有名管道和匿名管道)、消息队列、共享内存、信号量(semaphore)、信号(signal)、socket

除此之外还有互斥量和条件变量的属性。

clone(2) 系统调用可以创建子进程,和 fork 不同的是,clone 可以更加精细地指定父子进程之间的资源共享,比如共享文件描述符表,进程地址空间等。 通过指定一些资源的共享,clone 创建的进程比 fork 创建的子进程和 pthread_create 创建的线程更加灵活。

线程与信号:pthread_sigmask()、sigwait()、pthread_kill()。

2.5 openmp 线程标准(相对于 posix 线程标准)

借助编译器来实现并发。跨语言。

OpenMP 入门与实例分析 - 知乎

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值