《APUE》笔记-第十一章-线程

重点:控制线程、pthread_create、pthread_exit、pthread_join、pthread_cleanup_push、pthread_cleanup_pop、互斥量、读写锁、条件变量

使用线程都要包含头文件:#include <pthread.h>

同一进程内的多个线程自动的可以访问相同的存储地址空间和文件描述符。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。

每个线程都包含有表示知晓环境所必需的信息,包括线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据

编译线程程序时,要在最后加上-lpthread选项,如:gcc mutex.c -o mutex -lpthread

1.pthread_equal、 pthread_self、 pthread_create

用结构pthread_t来表示,在linux中使用无符号长整型表示该数据类型。线程只有在它所属的进程上下文中才有意义。

//b比较两个线程ID是否相等

int pthread_equal(pthread_t tid1, pthread_t tid2);

相等:返回非0;否则,返回0


//获得自身的线程ID

pthread_t pthread_self(void);

返回值:调用线程的线程ID


//创建一个线程

int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void *), void *restrict arg)

返回值:成功,0;否则,返回错误编号

理解:

1. tidp:新创建的线程ID被设置成tidp指向的内存单元

2. attr:定制各种不同的线程属性(下章介绍)

3. start_rtn:新创建的线程从start_rtn函数的地址开始运行

4. arg:传给start_rtn函数的参数

5. 新创建的线程的挂起信号集被清除

6. 线程创建时并不保证哪个线程会先运行:是新创建的线程,还是调用线程。


创建一个线程,返回线程的pid和tid,程序如下:

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

#define ERR_EXIT(m)\
        {\
                perror(m);\
                exit(EXIT_FAILURE);\
        }

void print_id(const char *s)
{
        pid_t pid;
        pthread_t tid;
        pid = getpid();
        tid = pthread_self();//获取线程自身ID
        printf("%s:\npid = %lu tid = %lu(0x%lx)\n", s, pid, tid, tid);
}

void *thread_func(void *arg)//新创建线程从此函数的地址开始运行
{
        print_id("new thread");
        return((void *)0);
}

int main()
{
        pthread_t tid;
        print_id("main pthread");
        if (pthread_create(&tid, NULL, thread_func, NULL) != 0)
                ERR_EXIT("pthread_create() error");
        sleep(1);
        exit(0);
}
结果:


分析:

1. 主线程sleep(1),这是因为若主线程不休眠,它可能会调用exit(0)退出,则整个进程就会终止。

2. 程序通过pthread_self来获取线程ID,而不是通过变量tid,这是因为如果新线程在调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的tid内容,并不是正确的线程ID。

3. 尽管linux使用unsigned long来表示线程ID的,但它们看起来像指针。

4. 用%lu即unsigned long来表示线程ID

2.pthread_exit、 pthread_join、pthread_cancel、  pthread_cleanup_push、 pthread_cleanup_pop

如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止。

要想在不终止整个进程的情况下,终止单个线程,有以下3种方法:

1.从启动例程返回(通过return)

2.线程调用pthread_exit

3.线程可以被同一进程中的其他线程取消

void pthread_exit(void *rval_ptr);//rval_ptr是线程的退出码


//获取线程的退出码

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

成功,返回0;否则,返回错误编号

调用线程将阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消

可以调用pthread_join自动把线程置于分离状态

程序练习:获取终止线程的退出码

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

#define ERR_EXIT(m)\
        {\
                perror(m);\
                exit(EXIT_FAILURE);\
        }

void *thread_func1(void *arg)
{
        printf("thread 1 returning\n");
        return((void *)1);//线程的第一种退出方式
}

void *thread_func2(void *arg)
{
        printf("thread 2 exiting\n");
        pthread_exit((void *)2);//线程的第二种退出方式
}

int main()
{
        pthread_t tid1, tid2;
        void *tret;//线程的退出状态
        if (pthread_create(&tid1, NULL, thread_func1, NULL) != 0)
                ERR_EXIT("pthread_create() error");
        pthread_join(tid1, &tret);
        printf("thread 1 exit code: %ld\n", (long)tret);

        if (pthread_create(&tid2, NULL, thread_func2, NULL) != 0)
                ERR_EXIT("pthread_create() error");
        pthread_join(tid2, &tret);
        printf("thread 2 exit code: %ld\n", (long)tret);
        exit(0);
}
结果:


分析:

1.线程的退出码通过return 和 pthread_exit里的参数传递出去

2.pthread_join中的第二个参数是个二级指针,要注意

3.用%ld来表示线程的退出码 (long)tret

4.pthread_create和pthread_exit的无类型指针参数可以传递的值不止一个,可以传递一个结构的地址,但是,这个结构所使用的内存在调用者完成调用以后必须仍然是有效的。


//请求取消同一进程中的其他线程

int pthread_cancel(pthread_t tid);

成功,返回0;否则,返回错误编号


线程可以安排它退出时需要调用的函数,称为线程清理处理程序,它们的执行顺序与注册顺序相反

void pthread_cleanup_push(void (*rtn)(void *), void *arg);//注册线程清理程序

void pthread_cleanup_pop(int execute);//删除线程清理程序

注意:

1.push和pop必须相匹配,即它们的个数要相等

2.线程执行以下动作时,才执行清理程序

    调用pthread_exit时;响应取消请求时;用非0参数调用pthread_cleanup_pop时

3.以下情况,不会执行清理程序

   通过return返回;用参数0调用pthread_cleanup_pop;push和pop不匹配时,编译不通过

创建3个线程,测试调用线程清理程序的情况,程序如下:

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

#define ERR_EXIT(m)\
        {\
                perror(m);\
                exit(EXIT_FAILURE);\
        }

//线程退出时的清理函数
void cleanup(void *arg)
{
        printf("cleanup: %s\n", (char *)arg);
}

void *thread_func1(void *arg)
{
        printf("thread 1 start\n");
        pthread_cleanup_push(cleanup, "thread 1 first handler");
        pthread_cleanup_push(cleanup, "thread 1 second handler");
        printf("thread 1 push completed\n");
        if (arg)//是pthread_create里的参数arg
                return((void *)1);//return 不会调用线程清理函数
        pthread_cleanup_pop(0);
        pthread_cleanup_pop(0);
        //pthread_cleanup_pop(0);
}

void *thread_func2(void *arg)
{
        printf("thread 2 start\n");
        pthread_cleanup_push(cleanup, "thread 2 first handler");
        pthread_cleanup_push(cleanup, "thread 2 second handler");
        printf("thread 2 push completed\n");
        if (arg)//是pthread_create里的参数arg
                pthread_exit((void *)2);//pthread_exit 会调用线程清理函数
        pthread_cleanup_pop(0);
        pthread_cleanup_pop(0);
}

void *thread_func3(void *arg)
{
        printf("thread 3 start\n");
        pthread_cleanup_push(cleanup, "thread 3 first handler");
        pthread_cleanup_push(cleanup, "thread 3 second handler");
        printf("thread 3 push completed\n");
        pthread_cleanup_pop(1);//参数不为0,不删除线程清理函数
        pthread_cleanup_pop(0);//参数为0,删除相应的清理函数
        pthread_exit((void *)3);//pthread_exit 会调用线程清理函数
}

int main()
{
        pthread_t tid1, tid2, tid3;
        void *tret;
        if (pthread_create(&tid1, NULL, thread_func1, (void *)1) != 0)
                ERR_EXIT("pthread_create() error");
        if (pthread_create(&tid2, NULL, thread_func2, (void *)2) != 0)
                ERR_EXIT("pthread_create() error");
        if (pthread_create(&tid3, NULL, thread_func3, NULL) != 0)
                ERR_EXIT("pthread_create() error");

        if (pthread_join(tid1, &tret) != 0)
                ERR_EXIT("pthread_join() error");
        printf("thread 1 exit code: %ld\n", (long)tret);
        if (pthread_join(tid2, &tret) != 0)
                ERR_EXIT("pthread_join() error");
        printf("thread 2 exit code: %ld\n", (long)tret);
        if (pthread_join(tid3, &tret) != 0)
                ERR_EXIT("pthread_join() error");
        printf("thread 3 exit code: %ld\n", (long)tret);

        exit(0);
}
结果:


分析:
1.线程的起始地址函数里的arg,是在调用pthread_create时的最后一个参数

2.线程1通过return返回,所以不会调用线程清理函数

3.thread_func1中push和pop要匹配,不然即使程序不会执行后面的pop,但仍然编译不通过

4.线程2通过pthread_exit返回,会调用线程清理函数

5.线程清理函数的注册顺序与调用顺序相反

6.thread_func3中用一个非0,一个0的参数调用pthread_cleanup_pop,为0的会删除线程清理程序,所以线程3只调用了1个线程清理程序

3.线程同步机制

保持线程间同步有5种方法:互斥量、读写锁、条件变量、自旋锁、屏障

4.互斥量

先看一个程序:

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

#define NLOOP 5000
int counter = 0;

void *thread_func(void *arg)
{
        int i, val;
        for (i = 0; i < NLOOP; i++)
        {
                val = counter;
                printf("%lx: %d\n", (unsigned long)pthread_self(), val +1);
                counter = val +1;
        }
}

int main()
{
        pthread_t tidA, tidB;
        if (pthread_create(&tidA, NULL, thread_func, NULL) != 0)
                exit(-1);
        if (pthread_create(&tidB, NULL, thread_func, NULL) != 0)
                exit(-1);
        pthread_join(tidA, NULL);
        pthread_join(tidB, NULL);
        exit(0);
}
主线程创建了A,B两个线程,两个线程都要读取全局变量counter,打印值,增加counter。各自循环5000次,按道理说,最后counter结果应该是10000,但实际情况是:


多次试验后,发现结果在5000到6000左右,值不定。原因就在于,counter是线程A和线程B共享的变量,可能发生情况:线程A读counter = 10-->内核切换到进程B-->进程B读counter = 10-->进程B加1,counter = 11-->内核切换到进程A-->进程A加1,counter = 11。可见在这种情况下,线程A和线程B的线程间同步出问题了。

互斥量的思想就是,使得读和写成为一个原子操作,要么都执行,要么都不执行,不会向上面程序那样读到中间被打断。

相关函数

pthread_mutex_t:互斥量

//用PTHREAD_MUTEX_INITIALIZER静态初始化互斥量,如:

pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;


//动态初始化互斥量

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

attr:互斥量属性(下章介绍),attr=NULL代表默认属性


//反初始化

int pthread_mutex_destroy(pthread_mutex_t *mutex);


//对互斥量进行上锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁


//对互斥量进行解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);


//尝试对互斥量进行上锁

int pthread_mutex_trylock(pthread_mutex_t *mutex);

尝试对互斥量进行加锁,成功,不会出现阻塞,直接返回0;出错,返回EBUSY


//设置线程阻塞时间

int pthread_timed_lock(pthread_mutex_t *restrict mutex, const struct timespec *restrict tsptr);

当阻塞超过指定时间时,不会对互斥量进行加锁,而是返回错误码ETIMEDOUT


使用互斥量时一定要进行初始化

上述所有函数,成功:返回0;出错,返回错误编号


对上面程序进行改进,使用互斥量,使得读写成为一个原子操作。

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

#define NLOOP 5000
int counter = 0;
pthread_mutex_t mutex_lock = PTHREAD_MUTEX_INITIALIZER;//静态初始化互斥量

void *thread_func(void *arg)
{
        int i, val;
        for (i = 0; i < NLOOP; i++)
        {
                pthread_mutex_lock(&mutex_lock);
                val = counter;
                printf("%lx: %d\n", (unsigned long)pthread_self(), val +1);
                counter = val +1;
                pthread_mutex_unlock(&mutex_lock);
        }
}

int main()
{
        pthread_t tidA, tidB;
        if (pthread_create(&tidA, NULL, thread_func, NULL) != 0)
                exit(-1);
        if (pthread_create(&tidB, NULL, thread_func, NULL) != 0)
                exit(-1);
        pthread_join(tidA, NULL);
        pthread_join(tidB, NULL);
        exit(0);
}
结果:


分析:

1.在改变变量counter前,要求锁住互斥量,因此互斥量对变量提供了保护。

2.使用互斥量,使得线程间对共享资源的访问保持正确

死锁问题

1.如果某个线程试图对同一互斥量加锁两次,那么它自身就会陷入死锁状态。

2.线程1占有互斥量A,并试图锁住互斥量B,线程2占有互斥量B,并试图锁住互斥量A。因为两个线程都在互相请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。

3.写程序时应该尽量避免同时获得多个锁,如果一定有必要这么做,则有一个原则:如果所有线程在需要多个锁时都按相同的先后顺序(常见的是按Mutex变量的地址顺序)获得锁,则不会出现死锁。比如一个程序中用到锁1、锁2、锁3,它们所对应的Mutex变量的地址是锁1<锁2<锁3,那么所有线程在需要同时获得2个或3个锁时都应该按锁1、锁2、锁3的顺序获得。如果要为所有的锁确定一个先后顺序比较困难,则应该尽量使用pthread_mutex_trylock调用代替pthread_mutex_lock调用,以免死锁。

读写锁

读写锁和互斥量类似,读写锁有3种状态:读模式下加锁状态、写模式下加锁状态、解锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。读写锁非常适合于读的次数远大于写的次数。

当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。

pthread_rwlock_t:读写锁

pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);


int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_timedrdlock(pthread_rwlock_t *rwlock, const struct timespec *tsptr);

int pthread_rwlock_timedwrlock(pthread_rwlock_t *rwlock, const struct timespec *tsptr);

程序练习:线程1写、读变量,线程2读、写、读变量,程序如下

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

#define ERR_EXIT(m)\
        {\
                perror(m);\
                exit(EXIT_FAILURE);\
        }

struct foo
{
        int f_count;
        pthread_rwlock_t f_rwlock;//读写锁
};

//写变量
void add(struct foo *pf)
{
        pthread_rwlock_wrlock(&pf->f_rwlock);//写模式下锁状态
        pf->f_count++;
        pthread_rwlock_unlock(&pf->f_rwlock);
}

//读变量
int search(struct foo *pf)
{
        int count;
        pthread_rwlock_rdlock(&pf->f_rwlock);//读模式下锁状态
        count = pf->f_count;
        pthread_rwlock_unlock(&pf->f_rwlock);
        return count;
}

//分配结构体并初始化读写锁
struct foo *fooalloc()
{
        struct foo *tempfoo;
        tempfoo = (struct foo *)malloc(sizeof(struct foo));
        if (tempfoo == NULL)
                return NULL;
        tempfoo->f_count = 1;
        pthread_rwlock_init(&tempfoo->f_rwlock, NULL);//初始化读写锁
        return tempfoo;
}

//线程1写、读变量
void *thread_func1(void *arg)
{
        add((struct foo *)arg);//因为参数只能是void *类型,所以需要转换
        printf("thread 1 add ok\n");
        int count = search((struct foo *)arg);
        printf("thread 1 search ok, f_count = %d\n", count);
        return((void *)1);
}

//线程2读、写、读变量
void *thread_func2(void *arg)
{
        int count;
        count = search((struct foo *)arg);
        printf("thread 2 search ok, f_count = %d\n", count);
        add((struct foo *)arg);
        printf("thread 2 add ok\n");
        count = search((struct foo *)arg);
        printf("thread 2 search ok, f_count = %d\n", count);
        pthread_exit((void *)2);
}

int main()
{
        pthread_t tid1, tid2;
        void *tret;
        struct foo *pfoo = fooalloc();
        if (pfoo == NULL)
                ERR_EXIT("fooalloc() error");
        if (pthread_create(&tid1, NULL, thread_func1, (void *)pfoo) != 0)//参数只能是void *类型,需要转换
                ERR_EXIT("pthread_create() error");
        if (pthread_create(&tid2, NULL, thread_func2, (void *)pfoo) != 0)//参数只能是void *类型,需要转换
                ERR_EXIT("pthread_create() error");

        //获取进程退出码
        pthread_join(tid1, &tret);//参数是二级指针
        //打印进程退出码
        printf("thread 1 exit code: %ld\n", (long)tret);

        pthread_join(tid2, &tret);
        printf("thread 2 exit code: %ld\n", (long)tret);

        exit(0);
}
结果:


分析:

1.因为线程起始地址函数的参数是void *类型,所以可以传递任一一个类型的参数,但需要做类型转换。

2.在读、写变量之前都要求获取相应的读写锁,因此对变量提供了保护。

条件变量

写条件变量比较好的博客:互斥量与条件变量的配合再谈互斥锁与条件变量

条件变量的思路:既然有了互斥量,为什么还要弄出个条件变量出来呢?试想这种情况:假如程序当中要通过查看某个变量(此变量不是条件变量)是否满足一定条件,从而决定程序要不要继续往下执行时,互斥量对这个变量进行保护,因此每次查看都要先锁住互斥量。那么问题来了,假如这个变量不满足条件呢?按互斥量的做法,那就只能先解锁,过一段时间再重新查看该变量是否满足(因为这段时间内该变量可能发生了变化)。这就很麻烦了,每次查看的时候,得锁住互斥量,这样其他线程就得挂起了,而且,若变量不满足条件的话,还得解锁,然后过段时间又得重新上锁,关键是“过段时间”这时间多久也无法确定,真是麻烦。所以就有了条件变量。

使用条件变量具体过程:一个线程A要访问某个条件,还是先锁住互斥量,然后查看该条件是否满足,若不满足,则调用pthread_cond_wait,此函数将条件变量和互斥量作为参数,也就是通过此函数设置了一个条件变量,然后此函数释放互斥锁,并且使调用线程挂起,直到其他线程通过条件变量来唤醒此线程。由于线程A释放了互斥量,此时其他线程便可锁住互斥量了,假设线程B锁住了互斥量,然后线程B改变了作为条件的变量(注意区分“作为条件的变量”和“条件变量”,他俩不是一个东东),因为作为条件的变量被改变了,所以此时该变量在线程A里面可能就满足条件了呀,所以得通知下线程A,通过调用pthread_cond_signal通知,此函数以条件变量作为参数,意思就是唤醒在该条件变量上睡眠的线程,但是线程B一开始是锁住了互斥量的,所以调用此函数后得马上释放互斥量才行。此时函数pthread_cond_wait返回,重新锁住该互斥量,线程A被唤醒了,并且查看变量是否满足,并继续按逻辑执行。

基本过程如下:

线程A:

pthread_mutex_lock(&lock)

while(某个条件不满足)//注意是while不是if,因为从pthread_cond_wait返回后,并不意味着条件一定满足,所以需要再次返回检查,用if的话则无法返回检查。

{

        pthread_cond_wait(&cond, &lock);//对互斥量解锁-->线程挂起-->该函数返回时,条件变量被再次锁住

}

pthread_mutex_unlock(&lock)

线程B:

pthread_mutex_lock(&lock);

改变“某个条件”//条件发生了改变

pthread_cond_signal(&cond);//通知线程A

pthread_mutex_unlock(&lock);

条件变量对比互斥量优点:不用反复查看变量,不会占有互斥量,这样其他线程可以使用互斥量


pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//静态初始化条件变量

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);//动态初始化条件变量

int pthread_destroy(pthread_cond_t *cond);

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);//条件变量和互斥量一起使用

int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *tsptr);

int pthread_cond_signal(pthread_cond_t *cond);//至少唤醒一个在该条件变量上睡眠的线程

int pthread_cond_broadcast(pthread_cond_t *cond));//唤醒所有在该条件变量上睡眠的线程

程序练习:线程1对变量减1,变量为0时则不能减,需要等到变量不为0时,才能减;线程2对变量加1。程序如下:

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

#define ERR_EXIT(m)\
        {\
                perror(m);\
                exit(EXIT_FAILURE);\
        }

int g_val = 0;//作为条件的变量
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//互斥量,静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//条件变量,静态初始化

//线程1起始函数,线程1对g_val减1
void *thread_func1(void *arg)
{
        printf("thread 1 begin\n");
        pthread_mutex_lock(&lock);//对互斥量上锁
        while (g_val == 0)//注意这里是while不是if
        {
                printf("thread 1 wait......\n");
                pthread_cond_wait(&cond, &lock);//条件不满足,挂起等待
        }
        g_val--;
        printf("g_val = %d\n", g_val);
        pthread_mutex_unlock(&lock);//解锁互斥量
        printf("thread 1 end\n");
        return((void *)1);
}

//线程2起始函数,线程2对g_val加1
void *thread_func2(void *arg)
{
        sleep(1);//让线程1先执行
        printf("thread 2 begin\n");
        pthread_mutex_lock(&lock);//对互斥量进行上锁
        g_val++;
        if (g_val > 0 )
                pthread_cond_signal(&cond);//通知线程1,现在条件以满足,可以继续进行
        pthread_mutex_unlock(&lock);//解锁互斥量
        printf("thread 2 end\n");
        pthread_exit((void *)2);
}

int main()
{
        pthread_t tid1, tid2;
        if (pthread_create(&tid1, NULL, thread_func1, NULL) != 0)
                ERR_EXIT("pthread_create() error");
        if (pthread_create(&tid2, NULL, thread_func2, NULL) != 0)
                ERR_EXIT("pthread_create() error");
        pthread_join(tid1, NULL);
        pthread_join(tid2, NULL);
        exit(0);
}
结果:




评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值