TCP/IP网络编程之多进程服务端(二)

TCP/IP网络编程之多进程服务端(二)

信号处理

本章接上一章TCP/IP网络编程之多进程服务端(一),在上一章中,我们介绍了进程的创建和销毁,以及如何销毁僵尸进程。前面我们讲过,waitpid是非阻塞等待子进程销毁的函数,但有一个不好的缺点就是要每隔一段时间判断子进程是否销毁,只有销毁完后才能执行父进程接下去的程序。这样显然会造成父进程一些资源上的浪费,那么有没有又可以销毁子进程,又不用让父进程等待的解决方案呢?答案当然是有的

子进程终止的识别主体是操作系统,因此,若操作系统能主动告诉通知正忙于执行程序的父进程说,子进程已经终止了,于是父进程放下手上的工作,处理子进程终止相关事宜,这不是两全其美吗?为了实现这样的想法,我们引入了“信号机制”。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息。另外,为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”

信号与signal函数

?
1
2
#include <signal.h>
void (* signal ( int signo, void (*func)( int )))( int ); //为了在产生信号时调用,返回之前注册的函数指针

  

上述函数的返回值类型为函数指针,因此函数声明有些繁琐,现在讲解下函数声明:

  • 函数名:signal
  • 参数:int signo,void (* func)(int)
  • 返回类型:参数为int型,返回void型函数指针

调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在signal函数中注册的部分特殊情况和对应常数:

  • SIGALRM:已到通过调用alarm函数注册的时间,alarm也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM信号。可以设置忽略或者不捕获此信号,如果采用默认方式其动作是终止调用该alarm函数的进程
  • SIGINT:输入CTRL+C
  • SIGCHLD:子进程终止

接下来编写调用signal函数的语句完成的请求:子进程终止则调用mychild函数。此时mychild函数的参数应为int,返回值类型应为void。只有这样才能称为signal函数的第二个参数,另外,常数SIGCHLD定义了子进程终止的情况,应成为signal函数的第一个参数。也就是说,signal函数调用语句如下:

?
1
signal (SIGCHLD, mychild);

  

接下来编写signal函数的调用语句,分别完成如下两个请求:

  1. 已经通过alarm函数注册的时间,请调用timeout函数
  2. 输入CTRL+C时调用keycontrol函数

代表这两种情况的常数分别为SIGALRM和SIGINT,因此按如下方式调用signal函数

?
1
2
signal (SIGALRM, timeout);
signal (SIGINT, keycontrol);

  

以上就是信号注册的过程,注册好信号后,发生注册信号时,操作系统将调用该信号对应的函数。下面通过示例验证,先介绍alarm函数

?
1
2
#include<unistd.h>
unsigned int alarm(unsigned int seconds); //返回0或以秒为单位的距SIGALRM信号发生所剩时间

  

如果调用该函数的同时向它传递一个正整形参数,相应时间后(以秒为单位)将产生SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理

signal.c

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
 
void timeout( int sig)
{
     if (sig == SIGALRM)
         puts ( "Time out!" );
     alarm(2);
}
void keycontrol( int sig)
{
     if (sig == SIGINT)
         puts ( "CTRL+C pressed" );
}
 
int main( int argc, char *argv[])
{
     int i;
     signal (SIGALRM, timeout);
     signal (SIGINT, keycontrol);
     alarm(2);
 
     for (i = 0; i < 3; i++)
     {
         puts ( "wait..." );
         sleep(100);
     }
     return 0;
}

  

  • 第5、11行:分别定义信号处理函数,这种类型的函数被称为信号处理器(Handler)
  • 第9行:为了每隔两秒重复产生SIGALRM信号,在信号处理器中调用alarm函数
  • 第20、21行:注册SIGALRM、SIGINT信号及相应处理器
  • 第22行:预约两秒后产生SIGALRM信号
  • 第27行:为了查看信号产生和信号处理器的执行并提供每次100秒、共三次的等待时间,在循环中调用sleep函数。也就是说,再过300秒、约5分钟后终止程序,这还少相当长的一段时间,但实际执行时只需不到10秒,其原因后面解释

编译signal.c并运行

?
1
2
3
4
5
6
7
8
# gcc signal.c -o signal
# ./signal
wait...
Time out!
wait...
Time out!
wait...
Time out!

  

上述是没有任何输入时的运行结果,下面在运行过程中输入CTRL+C,可以看到输出“CTRL+C pressed”字符串。在下面的输出中,在打印第一次和第二次打印“wait”后输入CTRL+C,就会出现“CTRL+C pressed”字符串

?
1
2
3
4
5
6
7
# ./signal
wait...
^CCTRL+C pressed
wait...
^CCTRL+C pressed
wait...
Time out!

  

有一点必须说明:发生信号时将唤醒由调用sleep函数而进入阻塞状态的进程。调用函数的主题的确是操作系统,但进程处于睡眠状态时无法调用函数。因此,产生信号时,为了调用信号处理器,将唤醒由调用sleep函数而进入阻塞状态的进程。而且,进程一旦被唤醒,就不会再进入睡眠状态,即便还未到sleep函数中规定的时间也是如此。所以上述的示例不到10秒就结束,连续输入CTRL+C则有可能连5秒都不到

利用signal函数进行信号处理

前面所学的内容足以用来编写防止僵尸进程的代码,但这里还要再介绍一个sigaction函数,它类似于signal函数,而且完全可以替代signal函数,也更稳定。因为signal函数在Unix系列的不同操作系统中可能存在区别,但sigaction函数完全相同

?
1
2
#include <signal.h> 
int sigaction( int signo, const struct sigaction *act, struct sigaction *oldact); //成功时返回0,失败时返回-1

  

  • signo:与signal函数相同,传递信号信息
  • act:对应于第一个参数的信号处理函数(信号处理器)信息
  • oldact:通过此参数获取之前注册的信号处理函数指针,若不需要则传递0

声明并初始化sigaction结构体变量以调用上述函数,该结构体定义如下:

?
1
2
3
4
5
6
struct sigaction
{
     void (*sa_handler)( int );
     sigset_t sa_mask;
     int sa_flags;
}

  

此结构体的sa_handler成员保存信号处理函数的指针值(地址值),sa_mask和sa_flags的所有位均初始化为0即可。这两个成员用于指定信号相关的选项和特性,而我们的目的主要是防止僵尸进程,故省略

下面给出sigaction函数的示例

sigaction.c

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
 
void timeout( int sig)
{
     if (sig == SIGALRM)
         puts ( "Time out!" );
     alarm(2);
}
 
int main( int argc, char *argv[])
{
     int i;
     struct sigaction act;
     act.sa_handler = timeout;
     sigemptyset(&act.sa_mask);
     act.sa_flags = 0;
     sigaction(SIGALRM, &act, 0);
 
     alarm(2);
 
     for (i = 0; i < 3; i++)
     {
         puts ( "wait..." );
         sleep(100);
     }
     return 0;
}

  

  • 第15、16行:为了注册信号处理函数,声明sigaction结构体变量并在sa_handler成员中保存函数指针值
  • 第17行:调用sigemptyset函数将sa_mask成员的所有位初始化为0
  • 第18行:sa_flags成员同样初始化为0
  • 第19、21行:注册SIGALRM信号的处理器,调用alarm函数预约两秒后发送SIGALRM信号

编译sigaction.c并运行

?
1
2
3
4
5
6
7
8
# gcc sigaction.c -o sigaction
# ./sigaction
wait...
Time out!
wait...
Time out!
wait...
Time out!

  

这就是信号处理相关理论,以此为基础讨论僵尸进程销毁的方法

利用信号机制消灭僵尸进程

当子进程终止时将产生SIGCHLD信号,知道这一点,我们就很容易通过信号机制来消灭僵尸进程

remove_zombie.c

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
 
void read_childproc( int sig)
{
     int status;
     pid_t id = waitpid(-1, &status, WNOHANG);
     if (WIFEXITED(status))
     {
         printf ( "Removed proc id: %d \n" , id);
         printf ( "Child send: %d \n" , WEXITSTATUS(status));
     }
}
 
int main( int argc, char *argv[])
{
     pid_t pid;
     struct sigaction act;
     act.sa_handler = read_childproc;
     sigemptyset(&act.sa_mask);
     act.sa_flags = 0;
     sigaction(SIGCHLD, &act, 0);
 
     pid = fork();
     if (pid == 0)
     {
         puts ( "Hi! I'm child process" );
         sleep(10);
         return 12;
     }
     else
     {
         printf ( "Child proc id: %d \n" , pid);
         pid = fork();
         if (pid == 0)
         {
             puts ( "Hi! I'm child process" );
             sleep(10);
             exit (24);
         }
         else
         {
             int i;
             printf ( "Child proc id: %d \n" , pid);
             for (i = 0; i < 5; i++)
             {
                 puts ( "wait..." );
                 sleep(5);
             }
         }
     }
     return 0;
}

  

  • 第21~25行:注册SIGCHLD信号对应的处理器,若子进程终止,则调用第7行中定义的函数。处理函数中调用了waitpid函数,所以子进程将正常终止,不会成为僵尸进程
  • 第27、37行:父进程共创建两个子进程
  • 第48、51行:为了等待发生SIGCHLD信号,使父进程共暂停5次,每次间隔5秒,发送信号时,父进程将被唤醒,因此实际暂停时间不到25秒

编译remove_zombie.c并运行

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# gcc remove_zombie.c -o remove_zombie
# ./remove_zombie
Child proc id: 7678
Hi! I'm child process
Child proc id: 7679
wait...
Hi! I'm child process
wait...
Removed proc id: 7678
Child send: 12
wait...
Removed proc id: 7679
Child send: 24
wait...
wait...

  

可以看出,子进程并未编程僵尸进程,而是正常终止,接下来利用进程相关知识编写服务器端

基于多任务的并发服务器

之前的回声服务端每次只能向一个客户端提供服务,现在,我们将扩展回声服务端,使其可以同时向多个客户端提供服务。图1-2给出了基于多进程的并发回声服务端的实现模型

图1-2   并发服务端模型

从图1-2可以看出,每当客户端请求时,回声服务端都创建子进程以提供服务,请求服务的客户端若有五个,则将创建五个子进程提供服务。为了完成这个任务,需要经过如下过程:

  • 第一阶段:回声服务端(父进程)通过调用accept函数受理连接请求
  • 第二阶段:此时获取的套接字文件描述符创建并传递给子进程
  • 第三阶段:子进程利用传递来的文件描述符提供服务

此处容易引起困惑的是向子进程传递套接字文件描述符的方法,其实没什么大不了的,子进程会复制父进程拥有的所有资源,实际上根本不用另外经过传递文件描述符的过程

echo_mpserv.c

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
#define BUF_SIZE 30
void error_handling( char *message);
void read_childproc( int sig);
 
int main( int argc, char *argv[])
{
     int serv_sock, clnt_sock;
     struct sockaddr_in serv_adr, clnt_adr;
 
     pid_t pid;
     struct sigaction act;
     socklen_t adr_sz;
     int str_len, state;
     char buf[BUF_SIZE];
     if (argc != 2) {
         printf ( "Usage : %s <port>\n" , argv[0]);
         exit (1);
     }
 
     act.sa_handler = read_childproc;
     sigemptyset(&act.sa_mask);
     act.sa_flags = 0;
     state = sigaction(SIGCHLD, &act, 0);
     serv_sock = socket(PF_INET, SOCK_STREAM, 0);
     memset (&serv_adr, 0, sizeof (serv_adr));
     serv_adr.sin_family = AF_INET;
     serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
     serv_adr.sin_port = htons( atoi (argv[1]));
 
     if (bind(serv_sock, ( struct sockaddr *)&serv_adr, sizeof (serv_adr)) == -1)
         error_handling( "bind() error" );
     if (listen(serv_sock, 5) == -1)
         error_handling( "listen() error" );
 
     while (1)
     {
         adr_sz = sizeof (clnt_adr);
         clnt_sock = accept(serv_sock, ( struct sockaddr *)&clnt_adr, &adr_sz);
         if (clnt_sock == -1)
             continue ;
         else
             puts ( "new client connected..." );
         pid = fork();
         if (pid == -1)
         {
             close(clnt_sock);
             continue ;
         }
         if (pid == 0)
         {
             close(serv_sock);
             while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
                 write(clnt_sock, buf, str_len);
 
             close(clnt_sock);
             puts ( "client disconnected..." );
             return 0;
         }
         else
             close(clnt_sock);
     }
     close(serv_sock);
     return 0;
}
 
void read_childproc( int sig)
{
     pid_t pid;
     int status;
     pid = waitpid(-1, &status, WNOHANG);
     printf ( "removed proc id: %d \n" , pid);
}
void error_handling( char *message)
{
     fputs (message, stderr);
     fputc ( '\n' , stderr);
     exit (1);
}

  

  • 第29~32行:为防止产生僵尸进程而编写的代码
  • 第47、52行:第47行调用accept函数后,在第52行调用fork函数。因此,父子进程分别带有一个第47行生成的套接字(受理客户端连接请求时创建的)文件描述符
  • 第58~66行:子进程运行的区域,此部分向客户端提供回声服务,第60行关闭第33行创建的服务端套接字,这是因为服务端套接字文件描述符同样也传递到子进程,这一点稍后单独讨论
  • 第69行:第47行中通过accept函数创建的套接字文件描述符已复制给子进程,因此服务端需要销毁自己拥有的文件描述符,这一点稍后单独说明

编译echo_mpserv.c并运行

?
1
2
3
4
5
6
7
8
# gcc echo_mpserv.c -o echo_mpserv
# ./echo_mpserv 8500
new client connected...
new client connected...
client disconnected...
removed proc id : 7825
client disconnected...
removed proc id : 7823

    

通过echo_client程序连接服务端例1

?
1
2
3
4
5
6
7
8
9
# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Hello world!
Message from server:Hello world!
Input message(Q to quit):Hello Amy!
Message from server:Hello Amy!
Input message(Q to quit):Hello Tom!
Message from server:Hello Tom!
Input message(Q to quit):q

  

通过echo_client程序连接服务端例2 

?
1
2
3
4
5
6
7
8
9
# ./echo_client 127.0.0.1 8500
Connected..........
Input message(Q to quit):Hello Java!
Message from server:Hello Java!
Input message(Q to quit):Hello Python!
Message from server:Hello Python!
Input message(Q to quit):Hello Golang!
Message from server:Hello Golang!
Input message(Q to quit):q

  

启动服务端后,要创建多个客户端连接,可以验证通过服务端同时向大多数客户端提供服务

通过fork函数复制文件描述符

示例echo_mpserv.c中给出了通过fork函数复制文件描述符的过程,父进程将两个套接字(一个是服务端套接字,另一个是与客户端连接的套接字)文件描述符复制给子进程。文件描述符的实际复制多少有些难以理解,调用fork函数时复制父进程的所有资源,有些人可能认为也会同时复制套接字,但套接字并非进程所有,从严格意义上来说,套接字属于操作系统资源,只是进程拥有代表相应套接字的文件描述符。

示例echo_mpserv.c中的fork函数调用过程如图1-3所示,调用fork函数后,两个文件描述符指向同一套接字

图1-3   调用fork函数并复制文件描述符

图1-3所示,一个套接字中存在两个文件描述符,只有两个文件描述符都销毁后,才能销毁套接字。如果维持图中的连接状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法完全销毁套接字(服务端套接字同理)。因此,调用fork函数后,要将无关紧要的套接字文件描述符关掉,如图1-4所示

图1-4   整理复制的文件描述符

分割I/O程序的优点

我们已经实现的回声客户端的数据回声方式为:向服务端传输数据,并等待服务端回复。无条件等待,直到接收完服务端的回声数据后,才能传输下一批数据。传输数据后需要等待服务端返回的数据,因为程序中重复调用了read和write函数,只能这么写的原因之一是:程序在一个进程运行,但现在可以创建多个进程,因此可以分割数据收发过程,默认分割模型如图1-5所示:

图1-5   回声客户端I/O分割模型

从1-5可以看出,客户端的父进程负责接收数据,额外创建的子进程负责发送数据。分割后,不同进程分别负责输入和输出,这样,无论客户端是否从服务端接收完数据都可以进程传输。选择这种实现方式的原因很多,但最重要的一点是,程序的实现更加简单,也许有人质疑:既然多产生一个进程,怎么能算简化程序呢?其实,按照这种实现方式,父进程只需编写接收数据的代码,子进程只需编写发送数据的代码,所以会简化。实际上,在一个进程内同时实现数据收发逻辑要考虑更多细节,程序会更复杂

分割I/O程序的另一个好处是,可以提高频繁交换数据的程序性能,如图1-6

图1-6   数据交换方法比较

图1-6左侧演示的是之前回声客户端数据交换方式,右侧演示的是分割I/O后的客户端数据传输方式。服务端相同,不同的是客户端区域。分割I/O后的客户端发送数据时不必考虑接收数据的情况,因此可以连续发送数据,由此提高同一时间内传输的数据量,这种差异在网速较慢时尤为明显

回声客户端的I/O程序分割

既然我们知道I/O程序分割的意义,接下来通过实际代码进行实现,分割的对象是回声客户端,下面回声客户端可以结合之前的回声服务端echo_mpserv.c运行

echo_mpclient.c

  

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
#define BUF_SIZE 30
void error_handling( char *message);
void read_routine( int sock, char *buf);
void write_routine( int sock, char *buf);
 
int main( int argc, char *argv[])
{
     int sock;
     pid_t pid;
     char buf[BUF_SIZE];
     struct sockaddr_in serv_adr;
     if (argc != 3) {
         printf ( "Usage : %s <IP> <port>\n" , argv[0]);
         exit (1);
     }
 
     sock = socket(PF_INET, SOCK_STREAM, 0);
     memset (&serv_adr, 0, sizeof (serv_adr));
     serv_adr.sin_family = AF_INET;
     serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
     serv_adr.sin_port = htons( atoi (argv[2]));
 
     if (connect(sock, ( struct sockaddr *)&serv_adr, sizeof (serv_adr)) == -1)
         error_handling( "connect() error!" );
 
     pid = fork();
     if (pid == 0)
         write_routine(sock, buf);
     else
         read_routine(sock, buf);
 
     close(sock);
     return 0;
}
 
void read_routine( int sock, char *buf)
{
     while (1)
     {
         int str_len = read(sock, buf, BUF_SIZE);
         if (str_len == 0)
             return ;
 
         buf[str_len] = 0;
         printf ( "Message from server: %s" , buf);
     }
}
void write_routine( int sock, char *buf)
{
     while (1)
     {
         fgets (buf, BUF_SIZE, stdin);
         if (! strcmp (buf, "q\n" ) || ! strcmp (buf, "Q\n" ))
         {
             shutdown(sock, SHUT_WR);
             return ;
         }
         write(sock, buf, strlen (buf));
     }
}
void error_handling( char *message)
{
     fputs (message, stderr);
     fputc ( '\n' , stderr);
     exit (1);
}

  

  • 第34~37行:第35行调用的write_routine函数中只有数据传输相关代码,第37行调用的read_routine函数中只有数据输入相关代码。像这样分割I/O并分别在不同函数中定义,将有利于代码实现
  • 第62行:调用shutdown函数向服务端传输EOF,当然,执行第63行的return语句后,可以调用第39行的close函数传递EOF,但现在已通过第33行的fork函数调用复制了文件描述符,此时无法通过一次close函数调用传递EOF,因此需要通过shutdown函数调用另外传递

启动服务端

?
1
2
3
4
# ./echo_mpserv 8500
new client connected...
client disconnected...
removed proc id : 7941

 

编译echo_mpclient.c并运行

?
1
2
3
4
5
6
7
8
9
# gcc echo_client.c -o echo_client
# ./echo_mpclient 127.0.0.1 8500
Hello world!
Message from server: Hello world!
Hello Amy!
Message from server: Hello Amy!
Hello Tom!
Message from server: Hello Tom!
q

  

为了简化输出过程,与之前示例不同,不会输出提示字符串:“Input message(Q to quit):”。无论是否接收消息,每次通过键盘输入字符串都会输出前面的提示字符串,可能会造成输出混乱,所以上面示例就没在输出提示字符串

  

 

0
0
« 上一篇: TCP/IP网络编程之套接字的多种可选项
» 下一篇: TCP/IP网络编程之多种I/O函数
	</div>
	<div class="postDesc">posted @ <span id="post-date">2018-09-25 20:31</span> <a href="https://www.cnblogs.com/beiluowuzheng/">北洛</a> 阅读(<span id="post_view_count">68</span>) 评论(<span id="post_comment_count">0</span>)  <a href="https://i.cnblogs.com/EditPosts.aspx?postid=9690162" rel="nofollow">编辑</a> <a href="#" onclick="AddToWz(9690162);return false;">收藏</a></div>
</div>
<script type="text/javascript">var allowComments=true,cb_blogId=411871,cb_entryId=9690162,cb_blogApp=currentBlogApp,cb_blogUserGuid='b99fbb2a-e185-4a7a-4eef-08d54dba4453',cb_entryCreatedDate='2018/9/25 20:31:00';loadViewCount(cb_entryId);var cb_postType=1;var isMarkdown=false;</script>
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值