进程间通信(IPC)

进程间通信(Interprocess Communication,IPC)是指两个或者多个进程之间进行数据交换的过程 进程拥有独立的内存空间

  • 命令行参数(向子进程传递和exec系列函数)
  • 这里可以这么理解:在创建子进程的时候,命令行参数是共享的
  • 可以通过fork 的返回值,传递
  • 环境列表 (子进程继承父进程的环境列表和exec系列函数)
  • 信号 (信号本身就是一个数据,不同的信号表示不同的数据,sigqueue还可以携带信号附加值)
  • 文件 (文件就不赘述,Linux下万物皆文件)

  • 管道

  • 这里管道又可以细分为有名管道和无名管道
  • 有名管道
mkfifo fifo
echo 要写入的数据 > fifo
cat fifo
管道本身不存储数据。可以当成水管来理解
水桶(文件)才是存放数据的容器
水管是负责运输,并且一根水管不可能同时做到往水桶里加水和取水
  • 编程模型步骤 进程A 函数 进程B 步骤 1 创建管道 mkfifo —- 2 打开管道 open 打开管道 1 3 读写管道 read/write 读写管道 2 4 关闭管道 close 关闭管道 3 5 删除管道 unlink
  • 无名管道 只适用于父子进程之间的通信
#include
int pipe(int pipefd[2]);
//成功返回0   失败返回-1
pipefd[2]  作为输出参数
  • 编程步骤:
  • 通过输出参数pipefd得到两个文件描述符,其中 pipefd[0]用于读,pipefd[1]用于写
  • pipe函数在内核中创建管道文件,并打开两次,一次读,一次写
  • 需要在fork之前调用pipe函数
  • 调用fork创建子进程
  • 父子进程只允许使用无名管道的一端(如果进程想读,则必须关闭写,如果想写,则必须关闭读)
  • 写数据的进程关闭读端(pipefd[0]),读数据的进程关闭写端(pipefd[1])
  • 父子进程传输数据
  • 父子进程分别关闭自己的文件描述符
  • 内存映射(mmap)
  • mmap/munmap底层不维护任何东西,只是返回一个首地址,所分配内存位于堆中
  • brk/sbrk底层维护一个白板纸地,记录所分配内存的结尾位置,所分配内存位于堆中,底层调用mmap/munmap
  • malloc底层维护一个双向链表和必要的控制信息,不可越界访问,所分配内存位于堆中,底层调用brk/sbrk
  • 每个进程都有虚拟的内存空间,虚拟内存地址只是一个数字 ,并没有和实际的物理内存将关联
  • 所谓内存分配与释放,其本质就是建立或者取消虚拟内存和物理内存之间的映射关系
  #include
  //虚拟内存映射到物理内存或者文件
  void *mmap(
      void *addr,     //虚拟内存起始位置,如果为NULL则系统自动选定合适的虚拟内存,成功则返回 一般给NULL
      size_t length,  //映射长度,以字节为单位,自动按照(4K)页对齐
      int prot,       //映射权限
      int flags,      //映射标志
      int fd,         //文件描述符,如果映射到文件则需要指定  如果不是映射到文件(匿名映射)则给0即可
      off_t offset    //文件偏移量,自动按照页(4k)对齐
  );
  /*
  成功返回映射区内存的起地址,失败返回-1 (MAP_FAILED)
  prot  权限取值:
  PROT_EXEC  -  映射区可执行
  PROT_READ  -  映射区可读
  PROT_WRITE -  映射区可写
  PROT_NONE  -  映射区不可访问
      如果既需要读,也需要写,则  PROT_READ|PROT_WRITE
  flags  映射标志:
  MAP_FIXED          - 若在addr内存地址上无法创建映射,则失败(无此标志系统会自动调整合适位置)
  MAP_SHARED         - 对映射区域的写入操作直接写入到文件中
  MAP_PRIVATE        - 对映射区的写入操作只写入到缓冲区中,不会真正写入到文件
  MAP_ANONYMOUS      - 匿名映射  将虚拟内存映射到物理内存而非文件    忽略fd 和 offset参数
  MAP_DENYWRITE      - 拒绝其它对文件的写入操作
  MAP_LOCKED         - 锁定映射区域,保证其不被置换
      一定需要 MAP_SHARED 和 MAP_PRIVATE  二选一
  */

  //取消内存映射
  int munmap(void *addr,size_t length);
  • 内核为每个进程间通信维护一个结构体形式的IPC对象
  • 该IPC对象可通过一个非负整数的IPC标识来引用
  • 与 文件描述符不同,IPC标识在使用时会持续加1,当达到最大值时,向0回转
  • 非负整数,唯一标识一个进程间通信的IPC对象

  • IPC标识是IPC对象的内部名称(编号)

  • 若多个进程需要在同一个IPC对象上会合(使用同一个进程间通信渠道),则必须通过键值作为其外部名称来引用该IPC对象,IPC键值外部名称
  • 无论何时,只要创建IPC对象,就必须指定个键值
  • 键值的数据类型在sys/types.h头文件中被定义为key_t类型,其原始类型就是长整型

  • 方式一: 服务器进程以PIC_PRIVATE为键值创建一个新的IPC对象,并将该IPC对象的标识存放在某外(如文件中),客户端进程就只可以去该文件中读取

  • 方式二: 在一个公共头文件中,定义一个两个进程都认可的键值,服务器进程用此键值创建IPC对象,客户端进程用该键值获取 IPC对象
  • 方式三: 两个进程事先约定好一个路径名和一个项目ID(0-255),通过路径名和ID调用ftok函数,将二者转换为一个唯一的键值
#include
#include

key_t ftok(const char *pathname,int pro_id);
pathname   -  一个真实存在的文件或者目录的路径名
pro_id     -  项目ID,低8位有效,其值域[0,255]

   成功返回键值,失败返回-1

注意:起作用的是pathname参数所表示的路径,而非pathname字符串本身
  • 共享内存基本特点:
  • 两个或者更多的进程,共享同一块系统内核负责维护的内存区域,其地址空间通常被映射到堆栈之间
  • 无需复制信息(数据),最快的一种IPC机制
  • 需要考虑同步访问的问题
  • 内核为每个共享内存,维护一个shmid_ds结构体形式的共享内存对象
#include
#include

//1.创建/获取共享内存     内核维护
int shmget(key_t key,size_t size,int shmflg);
/*
    A.以参数key作为键值创建共享内存,如果共享内存已经存在,则获取该共享内存
    B.size参数指定共享内存的大小(单位字节),建议取4096的整数倍
        若希望创建共享内存,则必须指定size参数
        若只是获取已有的共享内存,则size参数可以传递0
    C.参数shmflg标识
        0   -  获取,如果共享内存不存在则获取失败
        IPC_CREAT   - 创建,不存在则创建   存在则获取(除非指定IPC_EXCL)
                        如果IPC_CREAT需要给定共享内存的权限(mode)   IPC_CREAT|0644
        IPC_EXCL    - 排斥,和IPC_CREAT按位域,如果共享内存已经存在则失败
    成功返回共享内存的标识,失败返回-1
*/

//2.加载共享内存   将进程中的虚拟内存地址映射到共享内存中
void* shmat(int shmid,void *shmaddr,int shmflg);
/*
    A.将shmid(shmget的返回值)参数所标识的共享内存,映射到调用进程的地址空间
    B.可以通过参数shmaddr(进程中的虚拟地址)人为指定映射地址,也可以将参数置为NULL,由系统自动选择
    C.参数shmflg标识:
        0   - 以读写方式使用共享内存
        SHM_RDONLY  - 以只读方式使用共享内存
        SHM_RND - 只在shmaddr参数非NULL时才起作用,表示对shmaddr参数向下取内存页的整数倍作为映射地址

    成功返回映射地址,失败返回-1(0XFFFFFFF)

    如果加载成功,内核将该共享内存的加载计数加1(共享内存由内核维护,记录有多少个进程加载了该共享内存)
*/
//3.卸载共享内存
int shmdt(const void *shmaddr);
/*
    将参数shmaddr所指向加载的共享内存映射从调用进程的取消映射
    成功返回0,失败返回-1
    如果卸载成功,内核会将该共享内存的加载计数减1
*/

//4.销毁/控制共享内存
int shmctl(int shmid,int cmd,struct shmid_ds* buf);
/*
    A.参数shmid是shmget的返回值    是对shmid所标识的共享内存进行删除/获取共享内存的信息
    B.cmd取值
        IPC_STAT  -  获取共享内存的属性,通过buf参数输出
        IPC_SET   -  设置共享内存的属性,通过buf参数输入,仅三个属性可设置
                shm_perm.uid     用户ID
                shm_perm.gid     组ID
                shm_perm.mode    权限
        IPC_RMID  -  标记删除共享内存
                并非真正删除共享内存,只是做一个删除标记,禁止其被继续加载,但已有加载依然保留。
                只有当该共享内存的加载计数为0且使用IPC_RMID时才真正被删除
    成功返回0  失败返回-1
*/

struct shmid_ds{
    struct ipc_perm   shm_perm;   //所有者及权限
    size_t            shm_segsz;  //共享内存大小(以字节为单位)
    time_t            shm_atime;  //最后加载时间
    time_t            shm_dtime;  //最后卸载时间
    time_t            shm_ctime;  //最后修改时间
    pid_t             shm_cpid;   //创建共享内存的进程ID
    pid_t             shm_lpid;   //最后加载、卸载进程的ID
    shmatt_t          shm_nattch; //当前加载计数
    ...

};

struct ipc_perm{
    key_t       __key;   //键值
    uid_t       uid;     //有效属主ID
    gid_t       gid;     //有效属组ID
    uid_t       cuid;    //有效创建者ID
    gid_t       cgid;    //有效创建组ID
    unsigned short mode; //权限
    unsigned short __seq;//序列号
};

#ipcs -m       #查看当前系统的共享内存
#ipcrm -m shmid    #删除指定的共享内存
  • 消息队列基本特点
  • 消息队列是由一个系统内核负责存储和管理,并通过消息队列标识引用的数据链表
  • 可以通过msgget函数创建一个新的消息队列 ,或者获取一个已经存在的消息队列
  • 通过msgsnd函数向消息队列的后端追加消息(需要把消息从用户空间拷贝到内核空间)
  • 通过msgrcv函数从消息队列的前端按要求提取消息(需要把消息从内核空间拷贝到用户空间)
  • 消息队列中的每个消息除了消息本身数据以外,还包含消息类型和数据长度
  • 内核为每个消息队列 ,维护一个msqid_ds结构体形式的消息队列对象
#include

//msgget 创建或者获取消息队列
int msgget(key_t key,int msgflg);
/*
    A.该函数以参数key作为键值创建消息队列,如果存在则获取消息队列
    B.msgflg标识
        0       - 获取,不存在即失败
        IPC_CREAT   - 创建,不存在则创建,已存在则获取,除非      创建时需要给定权限 IPC|0644
        IPC_EXCL    - 排斥,创建时如果已经存在则创建失败
    成功返回消息队列标识,失败返回-1
*/
//msgsnd向消息队列发送消息
int msgsnd(int msgqid,const void *msgp,size_t msgsz,int msgflg);
/*
    A. msgqid 消息队列的标识  msgget函数的返回值
    B. msgp参数是一个指针,指针指向一块内存,内存中包含消息类型和消息数据
        内存中的前4/8个字节必须是一个大于0的整数,代表消息类型,其后紧跟消息数据
        消息数据的字节长度用msgsz参数表示     注意:msgsz长度并不包含消息类型4/8个字节
            +------------+--------------------+
    msgp--> |消息类型(>0) | 消息数据             |
            +------------+--------------------+
            | ||

    C.若内核中消息队列缓冲区有足够的空闲空间,则此函数会将消息拷入缓冲区并立即返回0,表示发送成功,否则此函数会阻塞,直到内核中的消息队列缓冲区有足够的空闲空间为止(比如有消息被接收)
    D.若msgflg参数包含IPC_NOWAIT位,则当内核中的消息队列没有足够空闲空间时,此函数不会阻塞,而是直接返回-1,且errno设置为EAGAIN
    成功返回0  失败返回-1
*/

//msgrcv 从消息队列中接收消息
ssize_t msgrcv(int msgqid,void *msgp,size_t msgsz,long msgtype,int msgflg);
/*
    A.msgqid 消息队列标识,msgget函数的返回值
    B.msgp指针指向一个包含消息类型(4byte)和消息数据的内存块,用于存储消息类型和消息数据本身
    C.msgsz参数用来标明消息数据缓冲区字节大小   msgp指针指向的内存块的大小-4/8byte
    D.若所接收到的消息字节数据大于msgsz参数,即消息太长
    E.如果msgflg参数中包含MSG_NOERROR位,则消息太长会被截取msgsz字节返回,剩余部分会被丢弃
        如果msgflg参数不包含MSG_NOERROR五个,消息太长时,不会对该消息做任何处理,直接返回-1,且errno设置为E2BIG
    F.msgtype参数表示期望接收哪类消息
        msgtype = 0  - 返回消息队列中的第一条消息
        msgtype > 0  - 若msgflg参数不包含MSG_EXCEPT位,则返回消息队列中第一个类型为msgtype的消息
                   如果msgflg参数包含MSG_EXCEPT位,则返回消息队列中第一个消息类型不为msgtype的消息
        msgtype < 0  - 返回消息队列中类型小于等于msgtype绝对值的消息
                   如果有多条消息满足,则返回消息类型最小的第一条消息
    G.若消息队列中有可接收的消息,则此函数会将该消息移出消息队列拷贝到msgp内存中并立即返回0,表示接收成功
        如果消息队列中没有可接收的消息,则此函数会阻塞,直到消息队列中有可接收的消息为止
    H.如果msgflg参数包含IPC_NOWAIT位,则当消息队列中没有可接收的消息时(没有满足要求的消息),则此函数不会阻塞,而是返回-1,设置errno为ENOMSG
    成功返回所接收到消息数据的字节数,失败返回-1

*/

//msgctl销毁/控制消息队列
int msgctl(int msgqid,int cmd,struct smqid_ds *buf);
/*
cmd的取值:
    IPC_STAT    - 获取消队列的属性,通过buf参数输出
    IPC_SET     - 设置消息队列的属性,通过buf输入
        msg_perm.uid
        msg_perm.gid
        msg_perm.mode
        msg_qbytes
    IPC_RMID    - 立即删除消息队列
        此时所有阻塞在该消息队列的,msgsnd/msgrcv函数调用都会立即返回失败,errno设置为EIDRM
    成功返回0   失败返回-1
*/

struct msqid_ds{
    struct ipc_perm    msg_perm;    //权限依赖
    time_t             msg_stime;   //最后发送时间
    time_t             msg_rtime;   //最后接收时间
    time_t             msg_ctime;   //最后修改时间
    unsigned long      _msg_cbytes; //消息队列中的字节数
    msgqumt_t          msg_qnum;    //消息队列中消息数
    msglen_t           msg_qbytes;  //消息队列能容纳的最大字节数
    pid_t              msg_lspid;   //最后发送消息进程ID
    pid_t              msg_lrpid;   //最后接收消息进程ID
};

struct ipc_perm{
    key_t       __key;   //键值
    uid_t       uid;     //有效属主ID
    gid_t       gid;     //有效属组ID
    uid_t       cuid;    //有效创建者ID
    gid_t       cgid;    //有效创建组ID
    unsigned short mode; //权限
    unsigned short __seq;//序列号
};
#ipcs -q   # 查看消息队列
#ipcrm -q msqid   # 删除指定的消息队列
  • 信号量基本特点
  • 本质上是用于限制对于共享资源访问的进程数量 计数器
    • 计数器如果设置为1,表示任意时刻只允许一个进程对共享资源进行访问 文件锁写锁 独占锁
  • 多个进程获取有限资源操作模式
    +
    1. 获取控制该资源的信号量
    2. 若信号量的值大于0,则进程可以使用该资源,为了表示该进程已获得该资源,需要将信号量的值减1
    3. 若信号等于0,则该进程休眠等待资源,直到信号量的值大于0,进程被唤醒,执行1步骤
    4. 当进程不再使用该资源时,为了表示进程释放该资源,需要将信号量的值加1,正在休眠等待该资源的其它进程将会被唤醒
  • 信号量 类似于 锁
#include

//semget  创建/获取信号量集  信号量数组
int semget(key_t key,int nsems,int semflg);
/*
    该函数是以key作为键值创建一个信号量集合(nsems参数表示集合中信号量的数量),如果是获取已经存在的信号量集合则nsems可以取0
    semflg取值:
        0       - 获取,不存在则失败
        IPC_CREAT   - 创建,不存在则创建,存在即获取,除非IPC_EXCL
        IPC_EXCL    - 排斥,和IPC_CREAT一起使用,如果信号量集合存在则失败
    成功返回信号量集合标识,失败返回-1

*/
//semop 操作信号量/信号量集合
int semop(int semid,struct sembuf *sops,unsigned nsops);
/*
    semid参数是信号量集合的标识,semget函数的返回值
    sops: 其实是一个数组的首地址   如果只有一个元素时,可以是一个元素的首地址
    nsops:数组长度
        sops数组中每个元素都是stuct sembuf的数据  执行操作如下:
            若sem_op大于0,则将其加到sem_num下标所表示的信号量的计数值上,以表示对资源的释放
            若sem_op小于0,则将其从sem_num下标所表示的信号量减去sem_op的绝对值,以表示对资源的获取
            若sem_num信号量的计数值不够减(信号量数值不能为负),则此函数会阻塞,直到该信号量够减为止,以表示对资源的等待;
            若sem_flg包含IPC_NOWAIT,则当sem_num信号量计数值不够减时,此函数不会阻塞,而是返回-1,errno设置为EAGAIN,以便在等待资源的同时还可以做其它处理
            若sem_op等于0,则直到sem_num所表示的信号量的计数值为0时才返回,除非sem_flg包含IPC_NOWAIT
    成功返回0,失败返回-1
*/

struct sembuf{
    unsigned short  sem_num;    //信号量下标    下标从0开始,表示操作哪一个信号量
    short           sem_op;     //操作数    1  -1
    short           sem_flg;    //操作标记
};

//semctl 销毁/控制信号量集
int semctl(int semid,int semnum,int cmd);
int semctl(int semid,int semnum,int cmd,union semun arg);
/*
IPC_STAT- 获取信号量集合的属性,通过arg.buf输出
IPC_SET - 设置信号量集合的属性,通过arg.buf输入
        sem_perm.uid
        sem_perm.gid
        sem_perm.mode
IPC_RMID- 立即删除信号量集合
        此时所有阻塞在对该信号量集合的semop函数调用,都会立即返回失败,errno设置为EIDRM

GETALL  - 获取信号量集合中每个信号量的计数值,通过arg.array输出
SETALL  - 设置信号量集合中每个信号量的计数值,通过arg.array输入
GETVAL  - 获取信号量集合中,下标为semnum信号量的计数值,通过返回值输出
SETVAL  - 设置信号量集合中,下标为semnum信号量的计数值,通过arg.val输入

注意:只有针对信号量集合中具体某个信号量操作时,才会使用semnum参数,针对整个信号量集合操作,会忽略semnum
成功因cmd而异,失败返回-1

*/

union emun{
    int            val;     //value for SETVAL
    struct sem_ds  *buf;    //Buffer for IPC_STAT  IPC_SET
    unsigned short *array;  //Array for GETALL  SETALL
    struct seminfo *__buf;  //buffer for IPC_INFO
};

struct sem_ds{
    struct ipc_perm     sem_perm;   //权限
    time_t              sem_otime;  //最后semop操作的时间
    time_t              sem_ctime;  //最后修改时间
    unsigned short      sem_nsems;  //信号量集合中信号量的数据
};

struct ipc_perm{
    key_t       __key;   //键值
    uid_t       uid;     //有效属主ID
    gid_t       gid;     //有效属组ID
    uid_t       cuid;    //有效创建者ID
    gid_t       cgid;    //有效创建组ID
    unsigned short mode; //权限
    unsigned short __seq;//序列号
};

Original: https://www.cnblogs.com/FlyingDoG–BoxPiG/p/16725644.html
Author: 打工搬砖日记
Title: 进程间通信(IPC)

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/574034/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • linux版powershell中,tab补全,linux外部命令参数名,的模块介绍

    关键字 linux powershell pwsh 补全 complete bash zsh 摘要:linux用户的福音!在linux版powershell中,补全linux外部命…

    Linux 2023年5月27日
    087
  • NC反弹shell的几种方法

    nc的作用 (1)实现任意TCP/UDP端口的侦听,nc可以作为server以TCP或UDP方式侦听指定端口 (2)端口的扫描,nc可以作为client发起TCP或UDP连接 (3…

    Linux 2023年6月14日
    083
  • linux下利用inode删除文件

    由于 linux下中文编码和在Windows中的中文编码可能不同,在一定的条件下,linux的文件夹可能会存在乱码的情况就算一些乱七八糟的字符。如问号的文件名,这样的文件使用rm …

    Linux 2023年6月6日
    0100
  • 用shell抓取某考试试题

    一、背景 最近公司组织考信息安全,但考试机构没有整理出试题,只给了以下几个在线练习的链接,想着用博客整理下题库题型,奈何这个只能用拍照图片,然后用图片转文字的方式太慢,累死个人了,…

    Linux 2023年6月6日
    096
  • vue过滤器和生命周期——day02

    vue之过滤器和生命周期——day02 过滤器: 概念:Vue.js 允许你自定义过滤器, 可被用作一些常见的文本格式化。过滤器可以用在两个地方: mustache 插值和 v-b…

    Linux 2023年6月7日
    0124
  • 子网掩码、前缀长度、IP地址数的换算

    子网掩码、前缀长度、IP地址数的换算 子网掩码 子网掩码只有一个功能,就是将IP地址划分为网络地址和主机地址两部分。 如同现实生活中的通讯地址,可以看作省市部分和具体门牌号部分。相…

    Linux 2023年6月6日
    0243
  • 监控平台SkyWalking9入门实践

    简便快速的完成对分布式系统的监控; 一、业务背景 微服务作为当前系统架构的主流选型,虽然可以应对复杂的业务场景,但是随着业务扩展,微服务架构本身的复杂度也会膨胀,对于一些核心的业务…

    Linux 2023年6月14日
    086
  • docker使用

    什么是虚拟化 在计算机中,虚拟化(英语:Virtualization)是一种资源管理技术,是将计算机的各种实体资源,如服务器、网络、内存及存储等,予以抽象、转换后呈现出来,打破实体…

    Linux 2023年6月14日
    082
  • Windows10公钥远程连接Linux服务器

    前言 一、环境准备 二、使用步骤 – 1.服务器安装并配置OpenSSH 2. 本地生成密钥 3. 服务器ssh添加密钥 三 总结 前言 使用公钥远程登陆Linux十分…

    Linux 2023年6月7日
    094
  • mysql select语句查询流程是怎么样的

    mysql select查询的数据是查询内存里面,如果没有查询的数据没有在内存,就需要mysql的innodb引擎读取磁盘,将数据加载的内存后在读取。这就体现了,mysql查询大量…

    Linux 2023年6月8日
    093
  • Redis in Action 文章投票

    首先在 Linux 开启 Redis 服务: 如果显示: 说明 Redis 服务已经开启,端口号 6379 redis.php init_data.php 用于添加案例的数据 vo…

    Linux 2023年5月28日
    0107
  • MySQL启动报:[ERROR] The server quit without updating PID file

    修改配置后 MySQL启动不了,报错: 看见这个不要惊慌,先把刚才修改的配置注释掉,看是不是配置有误!大部分是手误造成。 如果不行,再尝试一下方法: 解决方法 : 给予权限,执行 …

    Linux 2023年6月13日
    076
  • 使用URL快捷方式提高效率

    阅文时长 | 0.9分钟字数统计 | 1453.6字符主要内容 | 1、引言&背景 2、URL格式基本格式介绍 3、附录:Hotkey详细参数 4、拓展:收藏夹中的URL格…

    Linux 2023年6月14日
    092
  • docker compose容器编排

    Docker Compose (可简称Compose)是一个定义与运行复杂应用程序的 Docker 工具,是 Docker 官方 &#x7F16;&#x6392;&…

    Linux 2023年6月8日
    098
  • Linux 配置 IPv4或 IPv6地址

    Linux 配置 IPv4或 IPv6地址 配置 配置介绍 查看网络 ifconfig 网卡介绍 eth0 :本地网卡(CentOS7 是ens33) lo :内网网卡,管理内网I…

    Linux 2023年6月6日
    086
  • WSL系统安装与使用

    WSL是适用于 Linux 的 Windows 子系统,可让开发人员按原样运行 GNU/Linux 环境 – 包括大多数命令行工具、实用工具和应用程序 – …

    Linux 2023年5月27日
    0135
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球