linux进程间通信的本质

news2024/11/18 1:51:08

因为进程间具有独立性,你们想用进行进程间通信,难度还是比较大的。

进程间通信的本质就是让不同的进程看到同一份资源。

为什么要进行进程间通信——交互数据、控制、通知等目标

进程间通信的技术背景

  1. 进程是具有独立性的。虚拟地址空间+页表 保证进程运行的独立性(进程内核数据结构+进程的代码和数据)

  2. 通信成本会比较高

进程间通信的本质理解

  1. 进程间通信的前提是让不同的进程看到同一块“内存”

  2. 所谓的同一块“内存”,不隶属任何一个进程,而是更强调共享

对于Linux原生提供的管道是匿名管道

对于管道,有入口,有出口,有一个入口,有一个出口,管道都是单向传输内容的,管道中传输的资源就是数据。管道只支持单向通信,这是设计的原因

  • 在同步的提现中,若管道所有写段关闭,则从管道中读取完所有数据后,继续read会返回0,不再阻塞;若所有读端关闭,则继续write写入会触发异常导致进程退出

创建管道的过程:

  1. 分别以读写的形式打开同一个内存

  2. fork创建子进程

  3. 双方进程各种关闭自己不需要的文件描述符

匿名管道

pipe的用法

#include <unistd.h>

int pipe(int fd[2]);

该功能是创建一个无名管道 对于参数来说:fd文件描述符,fd[0]表示读端 对于返回值来说,成功返回0,失败返回错误代码 一个简单的使用例子:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;

int main()
{
    int pipearry[2]={0};
    int s=pipe(pipearry);

    assert(s==0);
    (void)s;
    //因为12行在release中没有用
    //为了防止在运行的时候出现警告信息用了第13行的代码
#ifdef DEBUGE
    cout<<"pipearry[0]"<<pipearry[0]<<endl;
    cout<<"pipearry[1]"<<pipearry[1]<<endl;

#endif
    pid_t pid=fork();
    if(pid==0)//child,关闭写端——pipearry[1]
    {
        //接受消息
        close(pipearry[1]);
        char buffer[1000];
        while(true)
        {
            ssize_t  ret=read(pipearry[0],buffer,sizeof(buffer));
            if(ret>0){
                buffer[ret]=0;
                cout<<"接收成功:接收的数据为->"<<buffer<<endl;
            }
            else if(ret==0){
                cout<<"父进程退出,子进程马上退出"<<endl;
                break;
            }
            else{
                cout<<"异常错误"<<endl;
                break;
            }
        }
        exit(0);

    }
    //father,关闭读端——pipearry[0]
    //写信息
    close(pipearry[0]);
    char buffer[]="我是父进程";
    int count=0;
    while(true){
        ssize_t ret = write(pipearry[1],buffer,sizeof(buffer));
        cout<<buffer<<':'<<count++<< "子进程pid"<<pid<< "父进程pid"<<getpid()<<endl;
        sleep(1);
        if(count==5){
            cout<<"父进程退出"<<endl;
            break;
        }
    }
    close(pipearry[1]);
    pid_t n= waitpid(pid,nullptr,0);
    cout<<"pid:"<<pid<<"n:"<<n<<endl;

    return 0;
}

总结管道的特点

  1. 管道是用来进行血缘关系的进程进行进程间的通信——常用于父子通信

  2. 管道具有通过让进程间协同,提供了访问控制!

  3. 写快,读慢,写满就不能在写了

  4. 写慢,读快,管道没有数据的时候,读必须等待

  5. 写关,读0,标识读到了文件结尾

  6. 读关,写继续写,os将中止写进程

  7. 管道提供的是面向流式的通信服务——面向字节流——协议

  8. 管道是基于文件的,文件的生命周期是随进程的,管道的生命周期是随进程的

  9. 管道是单向通信的,就是半双工通信是一种特殊情况

相关视频推荐

初识linux内核,进程通信还能这么玩

linux内核,进程间通信组件的实现

剖析Linux内核锁及进程间通信

免费学习地址:c/c++ linux服务器开发/后台架构师

需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

命名管道

命名管道说明该管道是有名字的,它和匿名管道的区别就是,匿名管道是内存基本的,在磁盘上没有存储,而命名管道在磁盘是存在的,但是里面是没有内容的。 这样双方进程就可以看见同一份资源了。

创建一个命名管道

命令行创建

mkfifo 文件名创建:

发送:

接受:

上面就是在命令行中完成的操作

在程序中创建

我们用下面这个函数

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

int mkfifo(const char *pathname, mode_t mode);
/*
pathname是路径名
mode是权限
返回值:成功返回0,错误返回-1、并设置错误码
*/

下面用命名管道实现server&client通信

Makefile

自动化编译

.PHONY:all
all:server client

server:server.cpp
 g++ -o $@ $^ -std=c++11
client:client.cpp
 g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
 rm -f server client

头文件包含相应的头:

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

#define FILEPATH "./namepipe"
#define MODE 0666
#define SIZE 512

server.cpp

创建namepipe匿名管道 接收来自用户端发来的消息

#include "common.hpp"

void getmessage(int fd)
{
    char buffer[SIZE]={0};
    while(true){
        ssize_t ret = read(fd,buffer,sizeof buffer);
        if(ret>0){
            std::cout<<"读取成功:pid为:"<<getpid()<<"内容为"<<buffer<<std::endl;
        }
        else if(ret==0){
            std::cout<<"读取完毕"<<"进程pid:"<<getpid()<<"退出"<<std::endl;
            break;
        }
        else{
            perror("读取错误");
            exit(1);
        }
    }
}

int main()
{
    if(mkfifo(FILEPATH,MODE)<0){
        perror("创建命名管道失败");
        exit(1);
    }
    //创建成功,打开文件
    int fd=open(FILEPATH,O_RDONLY);
    if (fd < 0)
    {
        perror("open");
        exit(1);
    }
    //创建3个子进程
    int n=3;
    for(int i=0;i<n;i++){
        pid_t pid=fork();
        if(pid==0){
            // 接收客户端发送的消息
            getmessage(fd);
            exit(3);
        }
    }
    //等待子进程退出
    for(int i=0;i<n;i++){
        waitpid(-1,nullptr,0);
    }
    close(fd);
    //删除命名管道
    std::cout<<"删除命名管道"<<std::endl;
    unlink(FILEPATH);
    return 0;
}

client.cpp

向服务器端发送消息

#include "common.hpp"


int main()
{
    int fd=open(FILEPATH,O_WRONLY);
    if(fd<0){
        perror("open");
        exit(1);
    }
    char buffer[SIZE]={0};
    while(true){
        std::cout<<"请输入你发送的信息";
        std::cin>>buffer;
        ssize_t ret = write(fd,buffer,sizeof buffer);
        if(ret<0){
            std::cout<<"写入数据错误"<<std::endl;
            break;
        }
    }
    close(fd);
    return 0;
}

删除文件

下面这个删除文件本质上是删除的该文件的链接个数

#include <unistd.h>

int unlink(const char *pathname);

system V共享内存

共享内存区是最快的IPC(进程间通信)形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核 ,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。共享内存是在用户空间中,可以直接像使用数组一样使用该空间

共享内存在哪个区域呢?——堆栈相对生长的区域就是共享内存的区域 既然是共享内存,需要os进行管理,那么就应该有它的数据结构

共享内存没有进行同步与互斥(共享内存缺乏访问控制),所以可能会出现,写入的数据的时候,还没有读取的时候就进行再一次的写入,就会读取不到原来的数据;或者是没有写完就进行读取,会导致读取的数据不完整

共享内存的数据结构为:

共享内存的创建和使用

先介绍几个函数

#include <sys/shm.h>
//用来创建共享内存
int shmget(key_t key, size_t size, int shmflg);
/*
key:共享内存段的名字,key_t 是int类型
size:共享内存大小

shmflg:用法和创建mode模式的标志一样
IPC_CREAT——>如果存在获取并返回,如果不存在,创建并返回
IPC_CREAT | IPC_EXCL——>如果底层不存在,创建并返回;如果存在,出错并返回
还可以再加权限。

返回值:成功返回非负整数,表示该共享内存的标识码,失败返回-1
*/

#include <sys/shm.h>
//将共享内存段连接到进程地址空间
void *shmat(int shmid, const void *shmaddr, int shmflg);
/*
shmid:共享内存的标识
shmaddr:指定连接的地址
shmflg:它的两个可能取值是SHM_RND(0)和SHM_RDONLY(非0)
返回值:
成功返回一个指针,指向共享内存第一个节;失败返回-1

参数的使用:
shmaddr为null,核心自动选择一个地址
shmaddr不为null且shmflg无SHM_RND标记,则以shmaddr为连接地址
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - 
(shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。
*/

#include <sys/shm.h>
//将共享内存段与当前进程脱离
int shmdt(const void *shmaddr);
/*
shmaddr:由shmat所返回的指针
返回值:成功返回0,失败返回-1
注意:将共享内存段与当前进程脱离不等于删除共享内存段
*/

#include <sys/shm.h>
//控制共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
/*
shmid:由shmget返回的共享内存标识码
cmd:将要采取的动作——>IPC_STAT,IPC_SET,IPC_RMID
IPC_RMID表示的是删除共享内存段
buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
就是共享内存数据结构的指针
返回值:成功返回0;失败返回-1
*/

server&client演示

我们想要client和server打开同一个共享内存,那么就要保证key一致,除了我们手动写死一个key的方式外,我们还可以用下面这个函数生成。

#include <sys/ipc.h>
//将路径名和项目标识符转换成system V的key
key_t ftok(const char *pathname, int proj_id);
/*  
pathname:路径名
proj_id:项目标识符
返回值:成功返回一个key_t值,失败时返回-1.
*/

Makefile

.PHONY:all
all:shmserver shmclient

shmserver:shmserver.cpp
 g++ -o $@ $^ -std=c++11
shmclient:shmclient.cpp
 g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
 rm -f shmserver shmclient

common.hpp

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <cstring>


#define FILEPATH "/home/lighthouse/studycode/"
#define FILENAME "./controlpipe"
// #define PROJID 0x888
#define PROJID 0x66
#define MODE 0666
#define SIZE 4096

using namespace std;
//创建命名管道,用于控制共享内存
class Init
{
public:
    Init()
    {
        int ret = mkfifo(FILENAME,MODE);
        if(ret<0){
            perror("命名管道");
            exit(-1);
        }
        cout<<"创建命名管道成功,用来对共享内存进行控制"<<endl;

    }
    ~Init()
    {
        unlink(FILENAME);
        cout<<"删除命名管道"<<endl;
    }
};
#define READ O_RDONLY
#define WRITE O_WRONLY
int OpenGFile(int flag)
{
    int fd = open(FILENAME, flag);
    if (fd < 0)
    {
        perror("open error");
        exit(-1);
    }
    return fd;
}
void Wait(int fd)
{
    cout<<"等待读取中……"<<endl;
    int datatemp=0;
    read(fd,&datatemp,sizeof(int));
    
}
void Wakeup(int fd)
{
    cout<<"读取中……"<<endl;
    int datatemp=0;
    write(fd,&datatemp,sizeof(int));
    
}
void Close(int fd)
{
    close(fd);
    cout<<"文件关闭成功"<<endl;
}

shmserver.cpp

#include "common.hpp"

void getmessage(int fd)
{
    char buffer[SIZE]={0};
    while(true){
        ssize_t ret = read(fd,buffer,sizeof buffer);
        if(ret>0){
            std::cout<<"读取成功:pid为:"<<getpid()<<"内容为"<<buffer<<std::endl;
        }
        else if(ret==0){
            std::cout<<"读取完毕"<<"进程pid:"<<getpid()<<"退出"<<std::endl;
            break;
        }
        else{
            perror("读取错误");
            exit(1);
        }
    }
}
Init init;

int main()
{
    //得到共享内存段的名字
    key_t key = ftok(FILEPATH,PROJID);
    if(key<0){
        perror("key");
        exit(1);
    }
    std::cout<<"得到共享内存段的名字key:"<<key<<std::endl;

    //创建一个全新的共享内存
    //MODE是添加的权限
    int shmid = shmget(key,SIZE,IPC_CREAT|IPC_EXCL|MODE);
    if(shmid<0){
        perror("create share memory");
        exit(2);
    }
    std::cout<<"创建一个全新的共享内存shmid:"<<shmid<<std::endl;
    //链接进程地址
    char* shmaddr = (char*)shmat(shmid,NULL,SHM_RND);
    if(shmaddr<0){
        perror("shmaddr error");
        exit(3);
    }
    std::cout<<"链接进程地址shmaddr:"<<(void*)shmaddr<<std::endl;
    
    //通信代码,接收信息,有信息才进行读取
    int fd = OpenGFile(READ);
    while(true)
    {
        Wait(fd);
        cout<<shmaddr<<endl;
        if(strcmp(shmaddr,"quit")==0)
        break;
    }
    Close(fd);
    // for(int i=0;i<5;i++){
    //     cout<<shmaddr<<endl;
    //     sleep(3);
    // }

    //断开与进程的连接
    int ret = shmdt(shmaddr);
    if(ret<0){
        perror("shmdt error");
        exit(4);
    }
    std::cout<<"断开与进程的连接"<<std::endl;

    //删除共享内存空间
    ret = shmctl(shmid,IPC_RMID,NULL);
    if (ret < 0)
    {
        perror("shmctl error");
        exit(5);
    }
    std::cout<<"删除共享内存空间"<<std::endl;
    return 0;
}

客户端不需要删除共享内存

shmclient.cpp

#include "common.hpp"


int main()
{
    key_t key = ftok(FILEPATH, PROJID);
    if (key < 0)
    {
        perror("key");
        exit(1);
    }
    std::cout << "得到共享内存段的名字key:" << key << std::endl;

    //不需要创建,获得共享内存的标识符即可
    int shmid = shmget(key, SIZE, 0);//0只是为了获得shmid
    if (shmid < 0)
    {
        perror("create share memory");
        exit(2);
    }
    std::cout << "得到共享内存shmid:" << shmid << std::endl;

    // 链接进程地址
    char* shmaddr = (char*)shmat(shmid, NULL, SHM_RND);
    if (shmaddr < 0)
    {
        perror("shmaddr error");
        exit(3);
    }
    std::cout << "链接进程地址shmaddr:" << (void*)shmaddr << std::endl;
    // 通信代码
    int fd = OpenGFile(WRITE);

    while(true)
    {

        cin>>shmaddr;
        Wakeup(fd);
        if (strcmp(shmaddr, "quit") == 0)
            break;
    }
    Close(fd);

    // for(int i=0;i<5;i++){
    //     cin>>shmaddr;
    // }
    // 断开与进程的连接
    int ret = shmdt(shmaddr);
    if (ret < 0)
    {
        perror("shmdt error");
        exit(4);
    }
    std::cout << "断开与进程的连接" << std::endl;

    //客户端不需要删除共享内存

    return 0;
}

查看共享内存的信息。

ipcs -m

删除共享内存资源

ipcrm -m shmid编号——>这是通过命令行的形式来删除的

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/756766.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Java基础---常用类大全以及各数据结构的方法大全

目录 前言 一、Math类 二.Scanner类 三、String类、StringBuilder和StringBuffer类 &#x1f496;String类 &#x1f496;StringBuilder和StringBuffer 四.Arrays类 五.Random类 六.时间类 七.ArrayList顺序表 八、LinkedList与链表 九.Stack栈和Queue队列 十.Pri…

UE5 DLC

前言 在网上找了很多文档,并没有介绍DLC如何创建,但是对比多篇文档后,可以总结为DLC也是Pak包,本质上还是补丁包,B站上有一篇视频介绍了: [UE4]如何在虚幻4游戏里添加DLC的教程(中英机翻)_哔哩哔哩_bilibili 但是也感觉不对,因为要改Build.cs文件。故研究了一下插件式…

【python】python五月国内社会消费品零售总额数据分析(代码+数据+报告)【独一无二】

&#x1f449;博__主&#x1f448;&#xff1a;米码收割机 &#x1f449;技__能&#x1f448;&#xff1a;C/Python语言 &#x1f449;公众号&#x1f448;&#xff1a;测试开发自动化 &#x1f449;荣__誉&#x1f448;&#xff1a;阿里云博客专家博主、51CTO技术博主 &#x…

ARM微控制器 AM2432BSEFHIALXR、AM2432BSFFHIALV技术参数(32位MCU)

1、AM2432BSEFHIALXR 32位MCU采用293引脚FCCSP封装&#xff0c;工作频率最高可达800MHz。该微控制器专为需要结合处理和实时通信的工业应用而构建&#xff0c;例如远程I/O模块和电机驱动器。 核心处理器&#xff1a;ARM Cortex-M4F&#xff0c;ARM Cortex-R5F 内核规格&#xf…

2.数据结构--空间复杂度

文章目录 一、空间复杂度讲解二、计算下列经典例题的空间复杂度1.冒泡排序的空间复杂度 O(1)2.斐波那契递归的空间复杂度 O(N)3.计算阶乘递归的空间复杂度 O(N) 三、时间复杂度和空间复杂度的对比四、常见的函数的时间复杂度和空间复杂度的总结 一、空间复杂度讲解 1.空间复杂…

复习第五课 C语言-初识数组

目录 【1】初识数组 【2】一维数组 【3】清零函数 【4】字符数组 【5】计算字符串实际长度 练习&#xff1a; 【1】初识数组 1. 概念&#xff1a;具有一定顺序的若干变量的集合 2. 定义格式&#xff1a; 数组名 &#xff1a;代表数组的首地址&#xff0c;地址常量&…

【iOS】ARC内存管理

内存管理 内存管理的思考方式iOS底层内存管理方式1. tagged pointer2. on-pointer iSA--非指针型iSA3. SideTables&#xff0c;RefcountMap&#xff0c;weak_table_t 内存管理有关修饰符__strong修饰符对象的所有者和对象的生命周期__strong对象相互赋值方法参数中使用__strong…

LeetCode 热题 100(一):哈希。49. 字母异位词分组、128. 最长连续序列。

LeetCode100链接&#xff1a;LeetCode 热题 100 - 学习计划 - 力扣&#xff08;LeetCode&#xff09;全球极客挚爱的技术成长平台 一、49. 字母异位词分组 题目要求&#xff1a; 给你一个字符串数组&#xff0c;请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。…

【ACM】—蓝桥杯大一暑期集训Day2

&#x1f680;欢迎来到本文&#x1f680; &#x1f349;个人简介&#xff1a;陈童学哦&#xff0c;目前正在学习C/C、Java、算法等方向&#xff0c;一个正在慢慢前行的普通人。 &#x1f3c0;系列专栏&#xff1a;陈童学的日记 &#x1f4a1;其他专栏&#xff1a;CSTL&#xff…

10秒搞定!教你如何轻松压缩jpg格式图片大小!

大家在日常拍照时&#xff0c;都会发现拍摄出来的JPG图片体积比较大&#xff0c;使用和保存时都会比较麻烦。那么该怎样压缩图片大小呢&#xff1f; 首先&#xff0c;我们需要了解什么是JPG压缩。JPG是一种有损压缩格式&#xff0c;通过减少图像中的信息量来使文件大小缩小。使…

Java实现多文件上传及进度条提示-源码下载

1、方案概述 1、案例框架使用的是springmvc5.3.16版本,jackson使用的是2.13.3版本。 2、前端使用的是Layui2.8.11框架,这个框架上手较为容易。 3、使用关键类CommonsMultipartResolver和MultipartHttpServletRequest实现多文件捕获。 2、效果预览 【选择文件】 【上传过…

Android JNI线程的同步 (十三)

🔥 Android Studio 版本 🔥 🔥 了解线程同步的两个变量 🔥 pthread_mutex_t 互斥锁 线程的互斥: 目前存在两个线程 , 线程A和线程B, 只允许只有一个资源对临界资源进程操作 (大概意思就是 : A线程 进入操作临界资源的时候 , 那么 B线程 就要进行等待 . 等到 A线程…

感受C++模版的所带来的魅力,扎实基础,扩展思维

一、泛型编程思想 首先我们来看一下下面这三个函数&#xff0c;如果学习过了 C函数重载 和 C引用 的话&#xff0c;就可以知道下面这三个函数是可以共存的&#xff0c;而且传值会很方便void Swap(int& left, int& right) {int temp left;left right;right temp; } …

Nuxt.js--》解密Nuxt.js:构建优雅、高效的现代化Vue.js应用

博主今天开设Nuxt.js专栏&#xff0c;带您深入探索 Nuxt.js 的精髓&#xff0c;学习如何利用其强大功能构建出色的前端应用程序。我们将探讨其核心特点、灵活的路由系统、优化技巧以及常见问题的解决方案。无论您是想了解 Nuxt.js 的基础知识&#xff0c;还是希望掌握进阶技巧&…

【C++】Eigen库实现最小二乘拟合

前言 入职第二周的任务是将导师的Python代码C化&#xff0c;发现Python中存在Numpy包直接调用np.polyfit就好了&#xff0c;但是C不存在需要造轮子。 #include <iostream> #include <cmath> #include <vector> #include <Eigen/QR> #include "x…

re学习(15)BUUCTF 2019红帽杯easyRe(寻找数据+xor问题)

参考视频&#xff1a; 【BUUCTF】每天一个CTF11“2019红帽杯easyRe”_哔哩哔哩_bilibili &#xff08;本人觉得看视频比看博客效率能提高十倍&#xff0c;呜呜呜&#xff0c;还是视频香~~~与君共勉&#xff09; 下载地址&#xff1a; BUUCTF在线评测 前言&#xff1a;虽然…

Spring Boot集成Redisson实现分布式锁

Spring Boot集成Redisson实现分布式锁 在分布式系统中&#xff0c;为保证数据的一致性和并发访问的安全性&#xff0c;我们经常会使用分布式锁来协调多个节点之间对共享资源的访问。Redisson是一个基于Redis的Java驻内存数据网格&#xff08;In-Memory Data Grid&#xff09;和…

【C++】string类模拟

文章目录 成员变量和查看接口迭代器&#xff08;读和读写&#xff09;operator[]&#xff08;读和读写&#xff09;c_str()size() 构造函数用字符串构造用对象构造&#xff08;两种方法&#xff09;析构 赋值运算符重载扩容和调整reserve()resize()clear() 增删查改push_back()…

C++的switch函数用法

一个 switch 语句允许测试一个变量等于多个值时的情况。每个值称为一个 case&#xff0c;且被测试的变量会对每个 switch case 进行检查。 语法 C 中 switch 语句的语法&#xff1a; switch(expression){ case constant-expression : statement(s); break; // 可选的 case c…

Redis数据类型 — Zset

目录 Zset内部设计 跳表哈希表 ZipList ZSet中每一个元素都需要指定一个score值和member值&#xff1a;<1> 可以根据score值排序后<2> member必须唯一<3> 可以根据member查询分数 Zset内部设计 因此&#xff0c;zset底层数据结构必须满足键值存储、键必…