进程/线程

线程与进程

进程:程序的一个执行实例,正在执行的程序,实际上就是分配系统资源(CPU时间,内存)的实体。查看进程/proc文件夹中查看。

线程:在一个程序中的一个执行路线叫做线程,也就是说线程是进程内部的一个控制序列。线程也被叫做轻量级进程(lwp)。

综上:进程是资源分配的基本单位,而线程是调度的基本单位,线程共享进程数据,但也拥有自己的一部分的数据,比如线程id,一组寄存器,栈,errno,信号屏蔽字,调度优先级。 对于线程共享的资源包括:定义的全局变量,文件描述符表,每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数,当前工作目录,用户id和组id。

进程

进程状态

执行:时间片轮转到该进程进行执行。

就绪:等该时间片结束之后进入就绪状态。

挂起:挂起主要是将暂不执行的进程换出到外存,节省内存空间。

阻塞:表示该进程正在等待一个事件的发生,阻塞状态下收到信号会切换到就绪状态。

挂起就绪:进程在外存中,但是只要被载入内存中就可以执行。

挂起阻塞:进程在外存中等待一个资源,即使被载入内存也无法被执行。

初始:初始化

退出:释放进程申请的一切资源进行退出。

Linux将进程的阻塞分为:暂停(stop),浅睡眠(sleep),深睡眠(disk sleep)。

进程的分类

1.僵尸进程:被终止但是资源还没被回收的进程,子进程退出,而父进程并没有调用wait或者waitpid来回收资源,就会产生僵尸进程,就会产生僵尸进程。僵尸进程的进程描述符仍然留在系统的进程表中。

2.孤儿进程:父进程先结束,子进程就会称为孤儿进程,会被init进程所接管,并由init进程调用wait等待其结束。

3.守护进程:在后台执行的程序,会以进程的形式初始化。创建守护进程:1.在父进程中fork(),并退出父进程,在子进程中调用setsid()创建新的会话;3.修改工作目录为“/”;4.关闭不需要的文件描述符,指的是fork()的时候,被子进程继承的父进程的没用的文件描述符;5.设置进程的umsak为0. 

进程/CPU调度算法

 时间⽚轮转(Round Robin,RR):优点:没有饥饿问题。问题:若时间⽚⼩,进程切换频繁,吞 吐量低;若时间⽚⻓,则响应时间过⻓,实时性得不到保证。

进程间通信

管道
匿名管道
int pipe(int fd[2]);
//fd:文件描述符数组,fd[0]表示读端,fd[1]表示写端

 

 管道通常是父子进程之间进行通信的,需要从父进程进行读取,则需要关闭父进程的写端口,子进程进行写入,需要关闭子进程的读端口。这里调用的读写函数是read和write

#include <iostream>
#include <cstring>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <assert.h>
#include <unistd.h>
using namespace std;

//父进程进行读取,子进程进行写入
int main()
{
    int fds[2];  //用来接受创建管道中的输出型参数
    int d = pipe(fds);
    assert(d==0);
    int id=fork();
    assert(id>=0);

    if(id==0)
    {
        //子进程进行写入,关闭读取
        close(fds[0]);
        //子进程
        int cnt=0;
        const char* s= "这是子进程给父进程发送消息";
        while(true)
        {
            cnt++;
            char buffer[1024];
            //c语言的格式化字符串的输出
            snprintf(buffer,sizeof buffer,"child-->parent: %s[%d][%d]",s,cnt,getpid());
            //将buffer的数据通过系统调用write写到管道中
            write(fds[1],buffer,strlen(buffer));
            if(cnt==5)
                break;
            //sleep(1);
        }
        close(fds[1]);
        exit(0);
    }

    //父进程进行读取,关闭写入
    close(fds[1]);
    while(true)
    {
        char buffer[1024];
        //调用系统调用read从管道文件中读取字符
        ssize_t n= read(fds[0],buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]=0;
            cout<<"#"<<buffer<<"| parent pid"<<getpid()<<endl;
        }
        else if(n==0) //当写入操作结束的时候,读取到最后一个字符的时候,可以退出
        {
            cout<<"管道内的内容已经被读取完成"<<endl;
            break;
        }
        
    }
    //父进程,需要等待子进程
    int status=0;
    int n=waitpid(id,&status,0);
    //status的低7位是进程退出码
    cout<<"pid-->"<<(status&0x7F)<<endl;
    assert(n==id);


    //[0]  读相当于一只嘴巴
    //[1]  写 相当于一只钢币
    // cout<<"fds[0]:"<<fds[0]<<endl;
    // cout<<"fds[1]:"<<fds[1]<<endl;
    //std::cout<<"hello c++"<<std::endl;

    return 0;
}

如果所有管道写端对应的文件描述符被关闭,则read返回0,如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

匿名管道的特点:1.通常是父子间进程进行通信的手段,或者共同祖先的进程之间进行通信,一个管道由该进程创建,然后该进程fork()。2.进程退出,管道释放,管道的声明周期随进程。3.内核会对管道操作同步和互斥。4.管道是半双工的,需要全双工的话,需要开两个管道。

命名管道

创建一个有名字的文件,使用

mkfifo filename
//也可以从函数里面创建
int mkfifo(const char* filename,mode_t mode)

 对于命名管道的打开用的是open,匿名管道用的是pipe()。

//Client.cpp
#include "Comm.hpp"
//这边是发送信息
int main()
{
    int wrd = open(NAMED_PIPE,O_WRONLY);
    if(wrd<0) exit(-1);
    char buffer[1024];
    while(true)
    {
        cout<<"Please say# ";
        fgets(buffer,sizeof(buffer),stdin);  //先往字符串写入字符
        if(strlen(buffer)>0) buffer[strlen(buffer)-1]=0;   //把最后一个字符改成/0
        ssize_t n =write(wrd,buffer,strlen(buffer));
        assert(n==strlen(buffer));
        (void)n;
    }
    close(wrd);
    return 0;
}



//server.cpp
#include "Comm.hpp"

//这边是接受信息,文件的创建和删除都在这边
int main()
{
    
    bool n = creatFifo(NAMED_PIPE);
    assert(n);
    (void)n;
    //开始从命名管道读数据
    int rfd = open(NAMED_PIPE,O_RDONLY);
    if(rfd<0) exit(-1);
    char buffer[1024];
    while(true)
    {
        ssize_t s = read(rfd,buffer,sizeof(buffer)-1);
        if(s>0)
        {
            buffer[s]=0;
            cout<<"client->server# "<<buffer<<endl;
        }
        else if(s==0)
        {
            cout<<"client quit,me too!"<<endl;
            break;
        }
        else{
            cout<<"err string:"<<strerror(errno)<<endl;
            break;
        }

    }
    remove(NAMED_PIPE);
    return 0;
}

//comm.hpp
#include <iostream>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <assert.h>
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
using namespace std;
#define NAMED_PIPE "/tmp/Namedpipe"


bool creatFifo(const string &path)
{
    umask(0);  //首先设置umask的值为0
    int n=mkfifo(path.c_str(),0666);  //创建的文件的权限是 rw
    if(n==0)
        return true;
    else
    {
        std::cout<<"errno: "<<errno <<"err string :" <<strerror(errno)<<endl;
        return false;
    }
}


void removeFifo(const string &path)
{
    int n = unlink(path.c_str());
    assert(n==0); //debug中的assert才有效,release模式下不执行assert
    (void)n;  
}

System V进程间通信

三种方式:System V消息队列    System V共享内存    System V信号量

共享内存函数

int shmget(key_t key, size_t size, int shmflg);
//功能:用来创建共享内存
//key:这个共享内存段名字
// size:共享内存大小
// shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的

void *shmat(int shmid, const void *shmaddr, int shmflg);
//功能:将共享内存段连接到进程地址空间
//shmid: 共享内存标识
//shmaddr:指定连接的地址,为NULL,随机选择一个地址
//shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY

int shmdt(const void *shmaddr);
//功能:将共享内存段与当前进程脱离
//shmaddr: 由shmat所返回的指针

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
//功能:用于控制共享内存
//shmid:由shmget返回的共享内存标识码
//cmd:将要采取的动作(有三个可取值)
// buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
#ifndef _COMM_HPP_
#define _COMM_HPP_

#include <iostream>
#include <stdio.h>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PROJ_ID  0x345
//共享内存的大小,一般建议是4KB的整数倍
//系统分配共享内存是以4KB为单位!----内存划分内存块的基本单位Page
//内核会给你向上取整  4097 ---4KB*2  内核给你的和你能用的是两码事,就是你申请4097的大小
//内核会给你4kb*2的大小
//虽然给了你4096*2的内存,但是你只能用4097
#define MAX_SIZE 4096

key_t Getftok(const char* pathname,int id)
{
    key_t k= ftok(PATHNAME, PROJ_ID); //可以获取同一个KEY!!
    if(k<0)
    {
        // cin, cout, cerr -> stdin, stdout, stderr -> 0, 1, 2
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        exit(1);  //退出错误码
    }
    return k;
}


int ShmHelper(key_t key,int mode)
{
    // key是shmget,设置进入共享内存属性中的!!!
    //用来表示该共享内存再内核中的唯一性!!
    int shmid = shmget(key,MAX_SIZE,mode); 
    if(shmid<0)
    {
        std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
        exit(2);
    }
    return shmid;
}
//创建共享内存
int CreateShm(key_t key)
{
    return ShmHelper(key,IPC_CREAT|IPC_EXCL|0666); //注意这里的0x666必须传,文件的权限,如果时0的画,就不能对共享内存文件进行操作
}
//获得贡献内存的shmid值
int GetShm(key_t key)
{
    return ShmHelper(key,IPC_CREAT);
}
//让进程和shmid关联
void* AttachShm(int shmid)
{
    void* start = shmat(shmid,nullptr,0); //再Linux系统中,是64位的系统,所以指针的大小是8字节
    //如果与int 4字节相比的话,会有精度缺失
    if((long long)start == -1L)
    {
        std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
        exit(3);
    }
    return start;
}
//去关联
void DtachShm(void* shmaddr)
{
    
    if(shmdt(shmaddr) == -1 )
    {
        std::cerr<<"DtachShm"<<errno<<":"<<strerror(errno)<<std::endl;
    }
}
//删除共享资源块
void Delshm(int shmid)
{
    if(shmctl(shmid,IPC_RMID,nullptr)==-1)
    {
        std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
    }
}

#endif

消息队列

消息队列是消息组成的链表,保存在内核中。消息队列中的消息是⼀个具有特定格式的数据块。操作系 统中可以存在多个消息队列,每个消息队列有唯⼀的 key 进⾏标识。

信号量

信号量是⼀种特殊的变量,对它的操作都是原⼦的(屏蔽中断),有两种操作:V(signal())和 P (wait())。V 操作会增加信号量 S 的数值,P 操作会减少它。(信号量 S 的值相当于记录资源的个 数)

信号

信号是系统为响应某些条件而产生的一个事件,接收到该信号的进程可以采取OS事先定义好的行为。类似于ctrl+c结束进程

套接字Socket

不同主机之间的进程进行通信,也可以本地通信。

协程

协程是⼀个⽤户态的线程,⽤户在堆上模拟出协程的栈空间。当需要进⾏协程上下⽂切换的时候,主线程只需要交换栈空间和恢复协程的⼀些相关的寄存器的状态,就可以实现上下⽂切换没有了从⽤户态 转换到内核态的切换成本,协程的执⾏也就更加⾼效。和传统的线程不同的是:线程是抢占式执⾏,当 发⽣系统调⽤或者中断的时候,交由OS调度执⾏;⽽协程是通过 yield 主动让出cpu所有权,切换到其他协程执⾏。

线程

线程间同步与互斥

同步与互斥:互斥锁,条件变量,信号量和读写锁。

信号量:允许多个线程同一时刻访问同一资源,但是需要限制在同⼀时刻访问此资源的最⼤线程数⽬,⼀般是将当前可⽤资源计数设置为最大资源计数,每增加⼀个线程对共享资源的访问,当前可⽤资源计数就会减1 ,只要当前可⽤资源计数是⼤于0 的,就可以发出信号。但是当前可⽤计数减⼩到0时则说明当前占⽤资源的线程数已经达到了所允许的最⼤数⽬,不能在允许其他线程的进⼊,此时的信号量信号将⽆法发出。

线程互斥中的共享变量

临界资源:多线程执行流共享的资源就叫做临界资源

临界区:每个线程内部,访问临界资源的代码,就叫做临界区

互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,即串行访问临界资源,通常对临界资源起保护作用,通常是加互斥锁.

原子性:具有一次性做完没有任何能中断的机制,叫做原子性。

操作共享变量就需要加上互斥锁,不然在同一时间进入临界资源的代码会出现问题。再加上锁之后,就被称为临界区,只允许一个线程进入操作,当该线程离开之后,其余线程才能申请到锁,对临界资源进行访问。

#include <iostream>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <memory>
// #include "PThread.hpp"
using namespace std;

int tickets=1000;   //共享资源火车票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;   //该锁如果是局部的,或者静态的必须使用init进行初始化,结束后要destroy
void* getTick(void* args)
{
// 1. 多个执行流进行安全访问的共享资源 - 临界资源
// 2. 我们把多个执行流中,访问临界资源的代码 -- 临界区 -- 往往是线程代码的很小的一部分
// 3. 想让多个线程串行访问共享资源 -- 互斥
// 4. 对一个资源进行访问的时候,要么不做,要么做完 -- 原子性 , 不是原子性的情况 -- 一个对资源进行的操作,如果只用一条汇编就能完成 -- 原子性
// 反之:不是原子的 -- 当前理解,方便表述

// 提出解决方案:加锁!

// 就需要尽可能的让多个线程交叉执行
// 多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换
// 线程一般在什么时候发生切换呢?时间片到了,来了更高优先级的线程,线程等待的时候。
// 线程是在什么时候检测上面的问题呢?从内核态返回用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换

    std::string username = static_cast<const char *>(args);  
    while (true)
    {
        // 加锁和解锁的过程多个线程串行执行的,程序变慢了!
        // 锁只规定互斥访问,没有规定必须让谁优先执行
        // 锁就是真正的让多个执行流进行竞争的结果
    std::string username = static_cast<const char *>(args);  
    while (true)
    {

        pthread_mutex_lock(&lock);   //加锁
        if (tickets > 0)
         {
            usleep(1254); // 1秒 = 1000毫秒 = 1000 000 微妙 = 10^9纳秒  
            //调用usleep的时候,会从用户态转向内核态
            std::cout << username << " 正在进行抢票: " << tickets << std::endl;
            tickets--;
            pthread_mutex_unlock(&lock);    //加锁和解锁的过程只针对于对临界资源的访问,也就是临界取的代码
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
        //抢完票就结束了嘛
        usleep(10000);  //形成一个订单给用户
    }
    return nullptr;

}
int main()
{

     unique_ptr<Thread> thread1(new Thread(getTick,(void*)"usr1",1));
    unique_ptr<Thread> thread2(new Thread(getTick,(void*)"usr2",2));
    unique_ptr<Thread> thread3(new Thread(getTick,(void*)"usr3",3));
    unique_ptr<Thread> thread4(new Thread(getTick,(void*)"usr4",4));

    thread1->join();
     thread2->join();
     thread3->join();
    thread4->join();
    return 0;
}
线程同步中的条件变量

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

条件变量:当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

//条件变量的定义
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
#pragma once
#include <iostream>
#include <pthread.h>
#include <queue>
#include "task.hpp"

using namespace std;
const int _max = 500;
template <class T>
class blockqueue
{
public:
    blockqueue(const int maxnum = _max):_cap(maxnum)
    {
        // 完成锁和条件变量的初始化
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_pcond, nullptr);
        pthread_cond_init(&_ccond, nullptr);
    }
    //生产者产生数据之后,放入到这里面
    void _push(const T &in) // 输入型参数 const&
    {
        pthread_mutex_lock(&_mutex);
        //1、判断
        //细节2:充当条件判断必须是while,不能用if
        //当你被唤醒的时候,可能存在异常或者伪唤醒的情况,pthread_cond_broadcast()把所有线程唤醒的话,如果此时只有一个位置,不进行判断的话,后面就容易出错。
        while(is_full())  //如果满了的话,就得等待
        {
            //细节1,pthread_cond_wait()这个函数的第二个参数必须是我们使用的互斥锁!
            //a、pthread_cond_wait:该函数调用的时候,会以原子性的方式,将锁释放,并将自己挂起
            //b、pthread_cond_wait:该函数被唤醒返回的时候,会自动的重新获取你传入的锁
            pthread_cond_wait(&_pcond,&_mutex); //因为生产条件不满足,无法生产,此时我们的生产者进行等待
        }
        //当运行到这里的时候,说明队列是不满的状态
        _q.push(in);   //将data数据存储进去
        //细节3:这个函数可以在临界区内部,也可以放在外部
        pthread_cond_signal(&_ccond); 
        pthread_mutex_unlock(&_mutex);

    }
    //主要作用是在消费者进行消费之后,弹出对应的数据
    void _pop(T *out) // 输出型参数
    {
        pthread_mutex_lock(&_mutex);
        //1、判断
        while(is_empty())
        {
            pthread_cond_wait(&_ccond,&_mutex);
        }
        //2、程序走到这里一定不会空
        *out=_q.front();
        _q.pop();

        //3、绝对保证,阻塞队列里面至少有一个空的位置
        pthread_cond_signal(&_pcond);
        pthread_mutex_unlock(&_mutex);
    }
    ~blockqueue()
    {
        pthread_cond_destroy(&_ccond);
        pthread_cond_destroy(&_pcond);
        pthread_mutex_destroy(&_mutex);
    }
private:
    int is_full()
    {
        return _q.size()==_cap;
    }
    int is_empty()
    {
        return _q.empty();
    }
private:
    int _cap; // 队列能够容纳的最大数量
    queue<T> _q;
    pthread_mutex_t _mutex; // 互斥锁
    pthread_cond_t _ccond;   // 消费者的条件变量
    pthread_cond_t _pcond;   // 生产者的条件变量
};
 信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。

#include <iostream>
#include <vector>
#include <pthread.h>
#include <semaphore.h>
#include <cassert>

const int getcap = 5;
template <class T>
class ring_queue
{
private:
    // P操作主要是对数据量进行预定
    void P(sem_t &sem)
    {
        int n = sem_wait(&sem);
        assert(n == 0);
        (void)n;
    }
    //主要是对信号量进行还原
    void V(sem_t &sem)
    {
        int n = sem_post(&sem);
        assert(n == 0);
        (void)n;
    }
public:
    ring_queue(const int cap = getcap) : _queue(cap), _cap(cap)
    {
        // 初始化信号量
        int n = sem_init(&_productor_sem, 0, _cap); // 初始化信号量,注意这里对生产者数量的初始化时剩余的可以放置任务线程的个数
        assert(n == 0);
        (void)n;
        n = sem_init(&_comsumer_sem, 0, 0);       //初始化消费者生产量,注意这里是对消费者可以消费的数量进行初始化,初始应该为0
        assert(n == 0);
        (void)n;
        //对消费者和生产者的位置进行初始化
        _spacePostion = _dataPostion = 0;

        //对锁进行初始化,这里需要两把锁,对于 生产者和消费者而言,他们是互斥的
        pthread_mutex_init(&_productor_mutex,nullptr);
        pthread_mutex_init(&_comsumer_mutex,nullptr);

    }
    void Push(const T &in)
    {
        // 主要是进行PV操作,首先需要明确的是PV操作是原子性的
        //首先对资源进行预定
        P(_productor_sem);
        
        //对这里进行加锁的时候需要注意,在P操作之后加锁,在V操作之前解锁
        //因为PV操作是原子性的,所以可以不用加锁,而且加锁的话会影响其余线程提前对资源的预定,浪费时间
        //如果在P操作之前加锁,那么必须等这个线程在释放锁之后,其余线程才能申请资源在进行后续操作 ,比较浪费时间
        //生产者消费者模型节约时间主要是在 在该生产者线程进行操作的时候,其余线程可以先去预定资源,申请任务,以及后续的在任务执行完成之后,并行完成后续的一系列操作
        pthread_mutex_lock(&_productor_mutex);
        _queue[_spacePostion++]=in;
        _spacePostion%=_cap;
        pthread_mutex_unlock(&_productor_mutex);

        V(_comsumer_sem); //消费者可以消费的资源+1

    }
    void Pop(T *out)
    {
        //主要是对消费者进行PV操作,首先需要注意的是这里需要对消费者P操作,生产者V操作
        //首先消费者对资源进行消费预定
        P(_comsumer_sem);
        pthread_mutex_lock(&_comsumer_mutex);
        *out = _queue[_dataPostion++];
        _dataPostion%=_cap;
        pthread_mutex_unlock(&_comsumer_mutex);
        V(_productor_sem); //生产者可以生产的资源+1
    }
    ~ring_queue()
    {
        // 销毁信号量
        sem_destroy(&_productor_sem);
        sem_destroy(&_comsumer_sem);
        //对锁进行销毁
        pthread_mutex_destroy(&_productor_mutex);
        pthread_mutex_destroy(&_comsumer_mutex);

    }

private:
    std::vector<T> _queue;
    int _cap;
    sem_t _productor_sem;
    sem_t _comsumer_sem;
    int _spacePostion;
    int _dataPostion;
    pthread_mutex_t _productor_mutex;
    pthread_mutex_t _comsumer_mutex;

};

死锁

死锁的四个必要条件:互斥条件:一个资源每次只能被一个执行流使用,串行访问;2.请求与保持条件:一个执行流因为申请资源而阻塞的时候,对已经获得资源保持不放;3.不剥夺条件:一个执行流已经获得的资源在未使用完之前,不能强行剥夺;4.循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值