33、进程间通信(IPC)全解析:从套接字到消息队列

进程间通信(IPC)全解析:从套接字到消息队列

在多进程编程中,进程间通信(IPC)是一个至关重要的概念。它允许不同的进程之间交换数据和同步操作,从而实现更复杂的功能。本文将深入探讨两种常见的 IPC 机制:基于套接字的通信和 System V IPC 中的消息队列。

1. UNIX 域套接字通信
1.1 UNIX 域套接字地址结构

在 UNIX 域中,套接字地址由 struct sockaddr_un 结构体表示:

struct sockaddr_un {
    short    sun_family;
    char     sun_path[108];
};

其中, sun_family 总是被设置为 AF_UNIX ,用于标识这是 UNIX 域的地址。 sun_path 包含了套接字的文件系统路径名。在绑定套接字时,这个文件会被实际创建。因此,服务器在调用 bind 之前,需要确保该文件不存在,如果存在则删除它,否则 bind 操作会因为地址已被使用而失败。

1.2 等待连接

如果服务器通过基于流的套接字提供服务,它需要使用 listen 函数通知操作系统,表明它已经准备好接受客户端的连接:

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

int listen(int s, int backlog);

s 是套接字描述符, backlog 指定了在任何给定时间可以挂起的连接请求数量。大多数操作系统会将其上限默认为 5。如果连接请求到达时,挂起连接队列已满,客户端将收到连接被拒绝的错误。

1.3 接受连接

服务器使用 accept 函数来实际接受连接:

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

int accept(int s, struct sockaddr *name, int *addrlen);

当有连接请求到达 s 所引用的套接字时, accept 会返回一个新的套接字描述符,服务器可以使用这个新描述符与客户端进行通信,而原来的描述符(绑定到知名地址的那个)可以继续用于接受更多的连接。如果 name 不为空,操作系统会将客户端的地址存储在那里,并将地址的长度存储在 addrlen 中。如果 accept 失败,它将返回 -1,并将失败原因存储在 errno 中。

1.4 连接到服务器

客户端使用 connect 函数连接到服务器:

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

int connect(int s, struct sockaddr *name, int addrlen);

该函数将 s 所引用的套接字连接到 name 所描述的服务器地址。 addrlen 指定了 name 中地址的长度。如果连接成功, connect 返回 0;否则,返回 -1 并将失败原因存储在 errno 中。客户端也可以使用 connect 连接数据报套接字到服务器,虽然这不是严格必需的,也不会实际建立连接,但可以让客户端在发送数据报时无需为每个数据报指定目标地址。

1.5 数据传输

在基于流的连接上传输数据时,客户端和服务器可以使用 read write 函数,也可以使用专门为基于流的套接字设计的 recv send 函数:

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

int recv(int s, char *buf, int len, int flags);
int send(int s, const char *buf, int len, int flags);

这两个函数与 read write 基本相同,只是多了一个 flags 参数。在 UNIX 域中,只有 MSG_PEEK 标志有意义。如果在调用 recv 时指定了该标志,数据会像往常一样被复制到 buf 中,但不会被“消耗”,再次调用 recv 会返回相同的数据,这允许程序在读取数据之前“窥视”数据,从而决定如何处理它。

当使用基于数据报的套接字时,服务器不需要调用 listen accept ,客户端通常也不需要调用 connect 。因此,操作系统无法自动确定这些套接字上的数据要发送到哪里,发送者必须每次都告诉操作系统数据的目标地址,接收者必须询问数据的来源。为此,定义了 recvfrom sendto 函数:

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

int recvfrom(int s, char *buf, int len, int flags,
        struct sockaddr *from, int *fromlen);
int sendto(int s, const char *buf, int len, int flags,
        struct sockaddr *to, int tolen);

sendto 函数通过 s 所引用的套接字将 buf 中的 len 字节数据发送到 to 所指定的服务器地址, tolen 指定了地址的长度。函数返回实际传输的字节数,如果发生错误则返回 -1。 recvfrom 函数从 s 所引用的套接字接收最多 len 字节的数据,并将其存储在 buf 中,数据来源的地址存储在 from 中, fromlen 会被修改以指示地址的长度。如果发生错误,函数返回 -1。

1.6 销毁通信通道

可以使用 close 函数关闭套接字,如果套接字是基于流的, close 会阻塞直到所有数据都被传输完毕。也可以使用 shutdown 函数关闭通信通道:

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

int shutdown(int s, int how);

how 的值决定了关闭的方式:
- 若 how 为 0,套接字关闭读操作,后续所有从该套接字的读取操作都将返回文件结束符。
- 若 how 为 1,套接字关闭写操作,后续所有向该套接字的写入操作都将失败,同时通知操作系统无需再尝试发送该套接字上的任何未完成数据。
- 若 how 为 2,套接字的读写操作都将关闭,该套接字基本变得无用。

1.7 示例代码

以下是一个简单的服务器和客户端程序示例,它们使用 UNIX 域套接字进行数据传输:

服务器端代码(socket-srvr)

#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <string.h>

#define SOCKETNAME  "mysocket"

int
main(void)
{
    char buf[1024];
    int n, s, ns, len;
    struct sockaddr_un name;

    /*
     * Remove any previous socket.
     */
    unlink(SOCKETNAME);

    /*
     * Create the socket.
     */
    if ((s = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        exit(1);
    }

    /*
     * Create the address of the server.
     */
    memset(&name, 0, sizeof(struct sockaddr_un));

    name.sun_family = AF_UNIX;
    strcpy(name.sun_path, SOCKETNAME);
    len = sizeof(name.sun_family) + strlen(name.sun_path);

    /*
     * Bind the socket to the address.
     */
    if (bind(s, (struct sockaddr *) &name, len) < 0) {
        perror("bind");
        exit(1);
    }

    /*
     * Listen for connections.
     */
    if (listen(s, 5) < 0) {
        perror("listen");
        exit(1);
    }

    /*
     * Accept a connection.
     */
    if ((ns = accept(s, (struct sockaddr *) &name, &len)) < 0) {
        perror("accept");
        exit(1);
    }

    /*
     * Read from the socket until end-of-file and
     * print what we get on the standard output.
     */
    while ((n = recv(ns, buf, sizeof(buf), 0)) > 0)
        write(1, buf, n);

    close(ns);
    close(s);
    exit(0);
}

客户端代码(socket-clnt)

#include <sys/types.h>
#include <sys/socket.h>
#include <string.h>
#include <sys/un.h>

#define SOCKETNAME  "mysocket"

int
main(void)
{
    int n, s, len;
    char buf[1024];
    struct sockaddr_un name;

    /*
     * Create a socket in the UNIX
     * domain.
     */
    if ((s = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        exit(1);
    }

    /*
     * Create the address of the server.
     */
    memset(&name, 0, sizeof(struct sockaddr_un));

    name.sun_family = AF_UNIX;
    strcpy(name.sun_path, SOCKETNAME);
    len = sizeof(name.sun_family) + strlen(name.sun_path);

    /*
     * Connect to the server.
     */
    if (connect(s, (struct sockaddr *) &name, len) < 0) {
        perror("connect");
        exit(1);
    }

    /*
     * Read from standard input, and copy the
     * data to the socket.
     */
    while ((n = read(0, buf, sizeof(buf))) > 0) {
        if (send(s, buf, n, 0) < 0) {
            perror("send");
            exit(1);
        }
    }

    close(s);
    exit(0);
}
2. System V IPC 函数
2.1 概述

System V IPC 包括消息队列、共享内存和信号量三种进程间通信机制。它们起源于 SVR2,现在大多数厂商都支持,在 SVR4 中也可以使用。每种 IPC 结构(消息队列、共享内存段或信号量)都由一个非负整数标识符引用。要使用一个消息队列,所有使用该队列的进程都必须知道其标识符。在创建 IPC 结构时,创建程序需要提供一个 key_t 类型的键,操作系统会将这个键转换为 IPC 标识符。键可以通过以下三种方式指定:
1. 使用 IPC_PRIVATE :服务器可以通过指定 IPC_PRIVATE 键来创建一个新的结构,创建过程会返回新结构的标识符。但问题是,客户端程序要使用该结构,必须知道这个标识符,因此服务器需要将标识符放在某个文件中供客户端读取。
2. 使用预定义的键值 :服务器和客户端可以在一个公共头文件中定义一个键值,服务器使用这个键创建新的 IPC 结构,客户端使用相同的键访问该结构。但这个键可能已经被其他程序使用,导致无法创建 IPC 结构。
3. 使用 ftok 函数生成键 :服务器和客户端可以约定一个文件系统中的现有文件路径名和一个项目 ID(值在 0 到 255 之间),调用 ftok 函数将这两个值转换为一个键:

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

key_t ftok(const char *path, int projectid);

然后在上述第 2 步中使用这个键。

2.2 创建和访问 IPC 结构

服务器通常调用相应的“get”函数来创建新的 IPC 结构,可以将键参数设置为 IPC_PRIVATE ,或者将键参数设置为某个键,并在标志参数中设置 IPC_CREAT 位。客户端通过调用相应的“get”函数,将键参数设置为合适的键,并清除标志参数中的 IPC_CREAT 位来访问服务器创建的现有 IPC 结构。为了确保创建的是新的 IPC 结构,而不是引用具有相同标识符的现有结构,可以在“get”函数的标志参数中设置 IPC_EXCL 位,如果 IPC 结构已经存在,“get”函数将返回错误。

每个 IPC 结构都有一个关联的权限结构,定义在 sys/ipc.h 中:

struct ipc_perm {
    uid_t     uid;
    gid_t     gid;
    uid_t     cuid;
    gid_t     cgid;
    mode_t    mode;
    ulong     seq;
    key_t     key;
    long      pad[4];
};

cuid cgid 标识创建对象的用户, uid gid 标识对象的所有者。 mode 是一组读写权限位,与文件的权限位相同,用于指定所有者、组和其他用户对对象的查看和修改权限。每种 IPC 类型的“控制”函数可以用于检查和修改这个结构。

System V IPC 机制的一个主要问题是,所有的 IPC 结构在系统中是全局的,并且没有引用计数。这意味着如果一个程序创建了这些结构之一,然后在没有销毁它的情况下退出,操作系统无法知道是否还有其他程序在使用它,因此只能保留该结构,直到有人手动删除它或系统重启。这可能会导致系统资源耗尽,因为系统对这些结构的数量有一定的限制。

3. 消息队列
3.1 消息队列概述

消息队列是一个消息链表,每个消息有固定的最大大小。消息按发送顺序添加到队列末尾,但每个消息可以有一个类型,允许在同一个队列中处理多个消息流。

3.2 获取消息队列标识符

在使用消息队列之前,进程必须使用 msgget 函数获取队列标识符:

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

int msgget(key_t key, int msgflg);

key 参数指定了消息队列使用的键,可以是 IPC_PRIVATE (此时总是会创建一个新的消息队列),也可以是一个非零值。如果 key 是非零值, msgget 将根据 msgflg 中是否设置了 IPC_CREAT 位来决定是创建新的消息队列还是返回现有消息队列的标识符。 msgflg 参数还用于指定消息队列的读写权限,与 open creat 函数的使用方式相同。如果成功, msgget 返回消息队列的标识符;如果队列不存在或无法创建,返回 -1,并将错误信息存储在 errno 中。

3.3 消息队列控制操作

msgctl 函数允许对消息队列执行多种控制操作:

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

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msqid 是要操作的消息队列标识符, buf 指向一个 struct msqid_ds 类型的结构,该结构描述了消息队列:

struct msqid_ds {
    struct ipc_perm    msg_perm;
    struct msg        *msg_first;
    struct msg        *msg_last;
    ulong              msg_cbytes;
    ulong              msg_qnum;
    ulong              msg_qbytes;
    pid_t              msg_lspid;
    pid_t              msg_lrpid;
    time_t             msg_stime;
    long               msg_pad1;
    time_t             msg_rtime;
    long               msg_pad2;
    time_t             msg_ctime;
    long               msg_pad3;
    kcondvar_t         msg_cv;
    kcondvar_t         msg_qnum_cv;
    long               msg_pad4[3];
};

msg_perm 描述了队列的权限位, msg_qnum msg_cbytes msg_qbytes 分别包含队列中的消息数量、队列中的字节数和队列的最大字节数。 msg_lspid msg_lrpid 分别包含最后发送和接收消息的进程 ID。 msg_stime msg_rtime msg_ctime 分别包含队列最后一次发送消息的时间、最后一次接收消息的时间和最后一次权限更改的时间。

msgctl cmd 参数可以取以下值:
- IPC_STAT :将 struct msqid_ds 结构的当前内容复制到 buf 指向的区域。
- IPC_SET :将 struct msqid_ds 结构中的 msg_perm.uid msg_perm.gid msg_perm.mode msg_qbytes 元素更改为 buf 指向区域中的值。此操作仅限于具有超级用户有效用户 ID 或等于 msg_perm.cuid msg_perm.uid 的进程, msg_qbytes 元素只能由超级用户更改。
- IPC_RMID :从系统中移除 msqid 指定的消息队列标识符,并销毁与之关联的消息队列和数据结构。此命令只能由具有超级用户有效用户 ID 或等于 msg_perm.cuid msg_perm.uid 的进程执行。

如果操作成功, msgctl 返回 0;如果发生错误,返回 -1,并将失败原因存储在 errno 中。

3.4 发送和接收消息

使用 msgsnd msgrcv 函数在消息队列上发送和接收消息:

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

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
int msgrcv(int msqid, void *msgp, size_t msgsz, long msgtype, int msgflg);

msgsnd 函数将 msgp 指向的、大小为 msgsz 的消息发送到 msqid 标识的消息队列。消息的结构如下:

struct msgbuf {
    long    mtype;
    char    mtext[];
};

mtype 是一个正整数,接收进程可以使用它来选择消息。 mtext 是一个大小为 msgsz 字节的缓冲区, msgsz 可以是 0 到系统规定的最大值(通常为 2048)之间的任意值。如果发送成功, msgsnd 返回 0;否则返回 -1,并将错误代码存储在 errno 中。

msgrcv 函数从 msqid 指定的消息队列中检索消息,并将其存储在 msgp 指向的区域,该区域足够大以容纳大小为 msgsz 字节的消息。 msgtype 参数控制要检索的消息:
- 如果 msgtype 为 0,返回队列中的下一个消息。
- 如果 msgtype 大于 0,返回队列中 mtype 等于 msgtype 的下一个消息。
- 如果 msgtype 小于 0,返回队列中 mtype 小于或等于 msgtype 绝对值的下一个消息。

如果成功接收消息, msgrcv 返回存储在 msgp 中的字节数;如果发生错误,返回 -1,并在 errno 中指示错误信息。

对于 msgsnd msgrcv msgflg 参数可以包含常量 IPC_NOWAIT 。如果消息队列已满, msgsnd 会立即返回错误,而不是阻塞直到有空间可用;如果没有指定类型的消息可用, msgrcv 会立即返回错误,而不是阻塞直到消息到达。

3.5 示例代码

以下是一个使用消息队列进行数据传输的简单服务器和客户端程序示例:

服务器端代码(msq-srvr)

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

#define MSQKEY      34856
#define MSQSIZE     32

struct mymsgbuf {
    long    mtype;
    char    mtext[MSQSIZE];
};

int
main(void)
{
    key_t key;
    int n, msqid;
    struct mymsgbuf mb;

    /*
     * Create a new message queue.  We use IPC_CREAT to create it,
     * and IPC_EXCL to make sure it does not exist already.  If
     * you get an error on this, something on your system is using
     * the same key - change MSQKEY to something else.
     */
    key = MSQKEY;
    if ((msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666)) < 0) {
        perror("msgget");
        exit(1);
    }

    /*
     * Receive messages.  Messages of type 1 are to be printed
     * on the standard output; a message of type 2 indicates that
     * we're done.
     */
    while ((n = msgrcv(msqid, &mb, MSQSIZE, 0, 0)) > 0) {
        switch (mb.mtype) {
        case 1:
            write(1, mb.mtext, n);
            break;
        case 2:
            goto out;
        }
    }

out:
    /*
     * Remove the message queue from the system.
     */
    if (msgctl(msqid, IPC_RMID, (struct msqid_ds *) 0) < 0) {
        perror("msgctl");
        exit(1);
    }

    exit(0);
}

客户端代码(msq-clnt)

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

#define MSQKEY      34856
#define MSQSIZE     32

struct mymsgbuf {
    long    mtype;
    char    mtext[MSQSIZE];
};

int
main(void)
{
    key_t key;
    int n, msqid;
    struct mymsgbuf mb;

    /*
     * Get a message queue.  The server must have created it
     * already.
     */
    key = MSQKEY;
    if ((msqid = msgget(key, 0666)) < 0) {
        perror("msgget");
        exit(1);
    }

    /*
     * Read data from standard input and send it in
     * messages of type 1.
     */
    mb.mtype = 1;
    while ((n = read(0, mb.mtext, MSQSIZE)) > 0) {
        if (msgsnd(msqid, &mb, n, 0) < 0) {
            perror("msgsnd");
            exit(1);
        }
    }

    /*
     * Send a message of type 2 to indicate we're done.
     */
    mb.mtype = 2;
    memset(mb.mtext, 0, MSQSIZE);
    if (msgsnd(msqid, &mb, MSQSIZE, 0) < 0) {
        perror("msgsnd");
        exit(1);
    }

    exit(0);
}

总结

本文详细介绍了 UNIX 域套接字和 System V IPC 中的消息队列两种进程间通信机制。UNIX 域套接字适用于本地进程之间的通信,提供了基于流和数据报的通信方式。而 System V 消息队列则允许进程通过消息的方式进行通信,每个消息可以有不同的类型,方便进行消息的分类和处理。通过这些机制,我们可以实现不同进程之间的数据交换和同步,构建出更加复杂和强大的应用程序。

希望本文能帮助你更好地理解和使用这些进程间通信机制,在实际开发中能够灵活运用它们来满足不同的需求。

进程间通信(IPC)全解析:从套接字到消息队列

4. 两种 IPC 机制的对比与选择
4.1 性能对比
  • UNIX 域套接字 :对于本地进程间通信,UNIX 域套接字的性能通常较高。基于流的 UNIX 域套接字在数据传输时,由于是在本地系统内进行,避免了网络协议栈的开销,数据传输速度快。例如,在高并发的本地服务中,使用 UNIX 域套接字可以快速处理大量的连接请求和数据传输。
  • 消息队列 :消息队列在处理大量小消息时,可能会有一定的性能开销。因为消息队列需要进行消息的排队、存储和检索操作。但是,消息队列的异步特性使得它在处理复杂的消息流和多进程协作时具有优势。
4.2 适用场景对比
  • UNIX 域套接字 :适用于需要实时、高效通信的场景,如本地服务器与客户端之间的通信。例如,数据库管理系统的本地客户端与服务器之间可以使用 UNIX 域套接字进行快速的数据交互。
  • 消息队列 :适用于需要异步处理、消息分类和排队的场景。例如,在一个分布式系统中,不同模块之间的消息传递可以使用消息队列,确保消息的顺序性和可靠性。
4.3 选择建议
  • 如果是本地进程间的实时通信,对性能要求较高,且通信模式较为简单,建议选择 UNIX 域套接字。
  • 如果需要处理复杂的消息流,支持异步通信,并且需要对消息进行分类和排队,消息队列是更好的选择。
5. 实际应用案例分析
5.1 基于 UNIX 域套接字的应用案例
  • Web 服务器与后端服务通信 :在一个 Web 应用中,Web 服务器(如 Nginx)可以通过 UNIX 域套接字与后端的应用服务器(如 PHP-FPM)进行通信。这样可以避免网络通信的开销,提高数据传输的效率。以下是一个简化的流程图,展示了这种通信过程:
graph LR
    A[Web 服务器] -->|UNIX 域套接字| B[后端应用服务器]
    B -->|处理结果| A
  • 数据库客户端与服务器通信 :数据库管理系统(如 MySQL)的本地客户端可以使用 UNIX 域套接字与数据库服务器进行通信。这种方式可以提供更快的响应速度,特别是在本地开发和测试环境中。
5.2 基于消息队列的应用案例
  • 任务调度系统 :在一个任务调度系统中,任务生产者(如定时任务脚本)可以将任务消息发送到消息队列中,任务消费者(如工作进程)从消息队列中获取任务并执行。这样可以实现任务的异步处理和负载均衡。以下是一个简单的任务调度系统的流程图:
graph LR
    A[任务生产者] -->|发送任务消息| B[消息队列]
    B -->|获取任务消息| C[任务消费者]
    C -->|执行任务| D[任务结果]
    D -->|反馈结果| A
  • 日志收集系统 :在一个分布式系统中,各个节点的日志信息可以通过消息队列进行收集和处理。每个节点将日志消息发送到消息队列,日志处理程序从消息队列中获取日志消息并进行存储和分析。
6. 常见问题及解决方案
6.1 UNIX 域套接字常见问题
  • 地址已被使用 :在服务器启动时,如果套接字文件已经存在, bind 操作会失败。解决方案是在服务器启动前,先检查并删除该套接字文件,如示例代码中的 unlink(SOCKETNAME)
  • 连接被拒绝 :当连接请求到达时,如果挂起连接队列已满,客户端会收到连接被拒绝的错误。可以通过调整 listen 函数的 backlog 参数来增加队列长度,但大多数操作系统会有上限限制。
6.2 消息队列常见问题
  • 队列满 :当消息队列已满时, msgsnd 函数会阻塞或返回错误。可以通过设置 msgflg 参数为 IPC_NOWAIT 来避免阻塞,或者增加消息队列的最大字节数。
  • 消息丢失 :在某些情况下,如系统崩溃或进程异常退出,可能会导致消息丢失。可以通过设置消息队列的持久化机制或使用消息确认机制来确保消息的可靠性。
7. 未来发展趋势
7.1 技术融合

随着技术的发展,未来可能会出现将 UNIX 域套接字和消息队列等多种 IPC 机制融合的情况。例如,在一个复杂的分布式系统中,同时使用 UNIX 域套接字进行本地进程间的高效通信,使用消息队列进行跨节点的异步消息传递。

7.2 性能优化

对于 IPC 机制的性能优化将是未来的一个重要方向。例如,通过优化内核实现、减少系统调用开销等方式,进一步提高 UNIX 域套接字和消息队列的性能。

7.3 安全性增强

随着对系统安全性要求的提高,未来的 IPC 机制可能会加强安全特性。例如,在消息队列中增加消息加密、身份验证等功能,确保消息的安全性和完整性。

总结

本文全面介绍了 UNIX 域套接字和 System V 消息队列这两种重要的进程间通信机制。通过对它们的原理、使用方法、示例代码的详细讲解,以及性能对比、应用案例分析和常见问题解决方案的介绍,希望读者能够深入理解这两种 IPC 机制,并在实际开发中根据具体需求选择合适的通信方式。同时,我们也展望了 IPC 技术的未来发展趋势,相信随着技术的不断进步,进程间通信将变得更加高效、安全和可靠。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值