当前位置: 首页 > news >正文

进程间通信详解(二):System V IPC 三件套全面解析

文章目录

  • 引言:
  • 一、System V 共享内存:
    • 1.1 核心概念
    • 1.2 共享内存示意图
    • 1.3 关键系统调用
    • 1.4 典型使用流程
    • 1.5 实例:通过共享内存实现进程间通信
      • 1.5.1 Shm.hpp
      • 1.5.2 Fifo.hpp
      • 1.5.3 server.cc
      • 1.5.4 client.cc
      • 1.5.5 演示
    • 1.6 值得注意的地方
  • 二、System V 消息队列:
    • 2.1 核心特性与应用模型
    • 2.2 关键系统调用
    • 2.3 典型应用示例:任务分发系统
    • 2.4 管理与调优​
  • 三、System V 信号量:
    • 3.1 核心概念:信号量集​
    • 3.2 关键系统调用
    • 3.3 互斥锁实现示例
    • 3.4 使用中的陷阱
  • 四、三种 System V IPC 机制对比
  • 五、总结​

引言:

在进程间通信(IPC)机制中,System V 提供了一套功能强大的通信方式:共享内存(Shared Memory)消息队列(Message Queue)信号量(Semaphore),统称为 System V IPC。它们广泛应用于多进程程序的通信与同步,是学习操作系统中 IPC 内容的重要组成部分。本文将从原理、接口函数、代码示例到适用场景,为你全面讲解这三种通信机制,并通过比较帮助你选择合适的方案。

一、System V 共享内存:

1.1 核心概念

System V 共享内存 是 Unix/Linux 系统中一种经典的进程间通信(IPC)机制。它允许多个无关的进程将同一块物理内存区域映射到它们各自的地址空间中,从而实现高效的数据共享。

核心概念与工作原理:

  1. 共享内存段(Shared Memory Segment):
    • 内核管理的一块特殊内存区域。
    • 不隶属于任何单个进程,独立于进程存在。
    • 可以被多个进程同时访问。
  2. 标识符(Identifier):
    • Key (key_t):一个用户指定的、系统范围的唯一键值(通常使用 ftok() 函数基于路径名和项目ID生成),用于在创建或获取共享内存段时标识它。
    • 共享内存ID (shmid):内核在成功创建或获取共享内存段后返回的唯一标识符。进程后续操作(附加、分离、控制)都使用这个 shmid
  3. 映射(Attach)
    • 进程通过系统调用 shmat() (Shared Memory Attach) 将共享内存段“附加”到自己的地址空间。
    • shmat() 返回一个指向共享内存段在进程地址空间中起始地址的指针 (shmaddr,通常设为 NULL 让系统自动选择地址)。
    • 之后,进程就可以像访问普通内存一样(通过这个指针)读写共享内存段中的数据。
  4. 分离(Detach)
    • 当进程不再需要访问共享内存段时,通过 shmdt() (Shared Memory Detach) 系统调用将其从自己的地址空间分离。
    • 分离操作不会删除共享内存段本身,只是断开进程与该段的连接。
  5. 控制(Control)
    • 使用 shmctl() (Shared Memory Control) 系统调用对共享内存段进行各种操作:
      • IPC_RMID:标记共享内存段为“待删除”。当最后一个附加到它的进程分离后,内核会真正删除该段及其数据结构。
      • IPC_STAT:获取共享内存段的元信息(如大小、权限、创建者等),存储在 struct shmid_ds 结构中。
      • IPC_SET:修改共享内存段的某些元信息(如权限 shm_perm)。
    • 创建共享内存段 (shmget()) 时也会隐式地进行控制操作。

1.2 共享内存示意图

在这里插入图片描述

1.3 关键系统调用

  1. shmget() - 获取共享内存段标识符
    • int shmget(key_t key, size_t size, int shmflg);
    • 功能:
      • 如果 key 对应的共享内存段不存在,且 shmflg 包含了 IPC_CREAT,则创建一个新的共享内存段。
      • 如果 key 对应的共享内存段已存在,则获取其标识符。
    • 参数:
      • key:共享内存段的键值 (或 IPC_PRIVATE 创建私有段)。
      • size:要创建或获取的共享内存段的大小(字节)。获取时通常设为0。
      • shmflg:标志位,指定创建/获取的权限和行为(如 IPC_CREAT | 0666 创建+读写权限)。
    • 返回值:成功返回共享内存ID (shmid),失败返回 -1
  2. shmat() - 附加共享内存段
    • void *shmat(int shmid, const void *shmaddr, int shmflg);
    • 功能:将 shmid 指定的共享内存段附加到调用进程的地址空间。
    • 参数:
      • shmidshmget() 返回的共享内存ID。
      • shmaddr:建议的附加地址。通常设为 NULL,让内核选择合适地址。
      • shmflg:附加标志(如 SHM_RDONLY 只读附加)。
    • 返回值:成功返回附加后的内存地址指针,失败返回 (void*) -1
  3. shmdt() - 分离共享内存段
    • int shmdt(const void *shmaddr);
    • 功能:将附加在地址 shmaddr(即 shmat() 返回值)的共享内存段从调用进程的地址空间分离。
    • 参数:shmaddrshmat() 返回的指针。
    • 返回值:成功返回0,失败返回-1
  4. shmctl() - 控制共享内存段
    • int shmctl(int shmid, int cmd, struct shmid_ds *buf);
    • 功能:对 shmid 指定的共享内存段执行控制命令 cmd
    • 参数:
      • shmid:共享内存ID。
      • cmd:控制命令(IPC_RMID, IPC_STAT, IPC_SET)。
      • buf:指向 struct shmid_ds 的指针,用于传入/传出信息(IPC_STAT/IPC_SET 时使用)。
    • 返回值:成功返回0IPC_RMID)或非负值(IPC_STAT/IPC_SET),失败返回-1

1.4 典型使用流程

  1. 创建者/服务器进程
    • 使用 ftok() 生成一个唯一的 key
    • 调用 shmget(key, size, IPC_CREAT | 0666) 创建共享内存段,获得 shmid
    • 调用 shmat(shmid, NULL, 0) 附加共享内存,获得指针 shared_mem
    • shared_mem 写入初始数据。
    • (可选)通知其他进程共享内存已就绪(可通过其他IPC机制如信号量、管道、消息队列等实现同步,这是使用共享内存的关键点,因为共享内存本身不提供同步机制)。
  2. 使用者/客户端进程
    • 使用相同的 ftok() 参数生成相同的 key
    • 调用 shmget(key, 0, 0) 获取已存在的共享内存段的 shmid。(注意 size=0
    • 调用 shmat(shmid, NULL, 0) 附加共享内存,获得指针 shared_mem
    • shared_mem 读取数据或写入数据(必须与创建者/其他使用者协商好数据结构和同步机制!)。
    • 使用完毕后调用 shmdt(shared_mem) 分离。
  3. 销毁
    • 通常由创建者或最后一个使用者负责销毁(但需协调)。
    • 调用 shmctl(shmid, IPC_RMID, NULL) 标记共享内存段为删除。
    • 内核会在最后一个附加进程分离后实际删除该段。

1.5 实例:通过共享内存实现进程间通信

1.5.1 Shm.hpp

#pragma once#include <iostream>
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include "Comm.hpp"
#include <sys/stat.h>const int gdefaultid = -1;
const int gsize = 4096;
const std::string pathname = "/home/zkp/linux/25/6/10/shm/";
const int projid = 0x66;
const int gmode = 0666;
#define CREATER "creater"
#define USER "user"class Shm
{
private:// 创建的一定要是一个全新的共享内存void CreateHelper(int flg){umask(0); // 把权限掩码设置为0,防止影响下面操作printf("key: 0x%x\n", _key);// 共享内存的生命周期,随内核_shmid = shmget(_key, _size, flg); // 核心创建函数if(_shmid < 0){ERR_EXIT("shmget");}printf("shmid: %d\n", _shmid);}void Create(){CreateHelper(IPC_CREAT | IPC_EXCL | gmode);}void Attach(){_start_mem = shmat(_shmid, nullptr, 0); // 映射到进程空间if((long long)_start_mem < 0){ERR_EXIT("shmat");}printf("attch sucess\n");}void Detach(){int n = shmdt(_start_mem);              // 解除映射if(n == 0){printf("detach sucess\n");}}void Get(){CreateHelper(IPC_CREAT);}void Destroy(){// if(_shmid == gdefaultid)//   return;Detach();  // 先接触映射if(_usertype == CREATER){int n = shmctl(_shmid, IPC_RMID, nullptr);  // 删除共享内存if(n == 0){printf("shmctl delete shm: %d success!\n", _shmid);}else{ERR_EXIT("shmctl");}}}public:Shm(const std::string &pathname, int projid, const std::string &usertype): _shmid(gdefaultid),_size(gsize),_start_mem(nullptr),_usertype(usertype){_key = ftok(pathname.c_str(), projid); // 生成唯一键值if(_key < 0){ERR_EXIT("ftok");}if(_usertype == CREATER)Create();           // 创建者:创建新的共享else if(_usertype == USER)Get();              // 使用者:获取现有共享内存else{}Attach();               // 映射到进程地址空间}void *VirtualAddr(){printf("VirtualAddr: %p\n", _start_mem);return _start_mem;}int Size(){return _size;}void Attr(){struct shmid_ds ds;int n = shmctl(_shmid, IPC_STAT, &ds); // ds: 输出型参数printf("shm_segsz: %ld\n", ds.shm_segsz);printf("key: 0x%x\n", ds.shm_perm.__key);}~Shm(){std::cout << _usertype << std::endl;if(_usertype == CREATER)Destroy();}private:int _shmid;             // 共享内存 idkey_t _key;             // 通过 ftok() 生成的键int _size;              // 共享内存大小(默认4096)void *_start_mem;       // 映射后的起始地址std::string _usertype;  // 用户类型
};

1.5.2 Fifo.hpp

#pragma once#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>#define PATH "."
#define FILENAME "fifo"class NameFifo
{
public:NameFifo(const std::string &path, const std::string &name): _path(path), _name(name){_fifoname = _path + "/" + _name;umask(0);// 新建管道int n = mkfifo(_fifoname.c_str(), 0666);if(n < 0){ERR_EXIT("mkfifo");}else{std::cout << "mkfifo sucess" << std::endl;}}~NameFifo(){// 删除管道文件int n = unlink(_fifoname.c_str());if(n == 0){ERR_EXIT("unlink");}else{std::cout << "remove fifo failed" << std::endl;}}
private:std::string _path;std::string _name;std::string _fifoname;
};class FileOper
{
public:FileOper(const std::string &path, const std::string &name): _path(path), _name(name), _fd(-1){_fifoname = _path + "/" + _name;}void OpenForRead(){// 打卡,write 方没有执行open的时候,read方,就要在open内部进行阻塞// 直到有人把管道文件打开了,open才会返回_fd = open(_fifoname.c_str(), O_RDONLY);if(_fd < 0){ERR_EXIT("open");}std::cout << "open fifo sucess" << std::endl;}void OpenForWrite(){// write_fd = open(_fifoname.c_str(), O_WRONLY);if(_fd < 0){ERR_EXIT("open");}std::cout << "open fifo sucess" << std::endl;}void Wakeup(){// 写入操作std::string message;int cnt = 1;pid_t pid = getpid();while(true){std::cout << "Plase Enter# ";std::getline(std::cin, message);message += (", message number: " + std::to_string(cnt++) + ", [" + std::to_string(pid) + "]");write(_fd, message.c_str(), message.size());}}void Read(){// 正常的readwhile(true){char buffer[1024];int number = read(_fd, buffer, sizeof(buffer) - 1);if(number > 0){buffer[number] = 0;std::cout << "Client Say# " << buffer << std::endl;}else if(number == 0){std::cout << "client quit! me too!" << std::endl;break;}else{std::cerr << "read error" << std::endl;break;}}}void Close(){if (_fd > 0)close(_fd);}int GetFd() const{return _fd;}~FileOper(){}private:std::string _path;std::string _name;std::string _fifoname;int _fd;
};

1.5.3 server.cc

#include "Shm.hpp"
#include "Fifo.hpp"int main()
{// 1. 创建FIFO(确保文件存在)NameFifo fifo(PATH, FILENAME);// 2. 创建共享内存Shm shm(pathname, projid, CREATER);char* mem = (char*)shm.VirtualAddr();// 3. 打开FIFO读端FileOper reader(PATH, FILENAME);reader.OpenForRead();std::cout << "Server ready. Waiting for client..." << std::endl;// 4. 事件循环while(true){// 读客户端通知reader.Read();// 处理共享内存std::cout << "Received data: " << mem << std::endl;// 退出条件if(strcmp(mem, "exit") == 0) break;}return 0;
}

1.5.4 client.cc

#include "Shm.hpp"
#include "Fifo.hpp"void AutoNotify(FileOper& writer, const char* msg)
{write(writer.GetFd(), msg, strlen(msg));
}int main()
{// 1. 打开FIFO写入端FileOper writerfile(pathname, FILENAME);writerfile.OpenForWrite();// 2. 获取共享内存Shm shm(pathname, projid, USER);char *mem = (char*)shm.VirtualAddr();// 3. 自动写入数据和通知const char* messages[] = {"Hello", "World", "IPC", "exit"};for(int i = 0; i < 4; ++i){strcpy(mem, messages[i]); // 安全写入// 自动通知服务端AutoNotify(writerfile, messages[i]);sleep(1);}// 4. 清理资源writerfile.Close();return 0;
}

1.5.5 演示

启动 server
在这里插入图片描述
看一下共享内存的状态:
在这里插入图片描述

会发现它阻塞在这里,此时我们启动 client
在这里插入图片描述
client 在传输完数据后退出。
serverclient 传输数据之后开始读取数据:
在这里插入图片描述

因为读取到了 exit(我设计退出的方式),所以退出并清理资源。
此时再看看共享内存的信息:
在这里插入图片描述

1.6 值得注意的地方

有的同学在边写代码边调试的时候,运行了一次程序,后面却发现怎么也创建不了共享内存了,这是因为我们创建的共享内存的生命周期是随内核的,需要我们手动去删除:

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x660228c6 29         zkp        146        4096       0                       zkp@zkp:~/linux/25/6/10/shm$ ipcrm -m 29
zkp@zkp:~/linux/25/6/10/shm$ ipcs -m------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      

二、System V 消息队列:

2.1 核心特性与应用模型

消息队列是进程间异步通信的 “信箱”,每个消息包含类型字段数据载荷。接收方可以根据类型选择性获取消息(如只接收类型 2 的消息),支持多生产者 - 多消费者模式。典型应用场景包括:​

  • 任务调度系统中的异步任务分发​
  • 微服务架构中的轻量级消息传递​
  • 日志系统的异步日志收集

2.2 关键系统调用

  1. msgget:创建 / 获取消息队列
    • int msgget(key_t key, int msgflg);
    • 参数
      • key:通过ftok(path, id)生成的唯一键值(如ftok("/dev/null", 'A')
      • msgflg:​
        • IPC_CREAT:不存在则创建​
        • IPC_EXCL:与IPC_CREAT组合时检查队列是否存在​
        • 权限掩码(如0660)
    • 返回值:队列 ID(msqid),失败返回 -1
  2. msgsnd:发送消息
    • int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
    • 消息结构体必须以long mtype开头:
      struct msgbuf 
      {long mtype;       // 消息类型(正整数)char mtext[1024]; // 数据载荷
      };
      
    • 参数
      • msgflg
        • ​0:阻塞直到消息入队​
        • IPC_NOWAIT:队列满时立即返回错误
      • msgsz:是 mtext 的实际长度(含终止符)
  3. msgrcv:接收消息
    • ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long mtype, int msgflg);
    • mtype过滤规则:
      • 0:接收任意类型(按 FIFO 顺序)​
      • >0:接收指定类型的第一条消息​
      • <0:接收类型小于等于绝对值的最小类型消息
    • msgflg:​
      • 0:阻塞直到有符合条件的消息​
      • IPC_NOWAIT:无消息时立即返回 -1
      • MSG_NOERROR:截断超长消息
  4. msgctl:队列控制
    • int msgctl(int msqid, int cmd, struct msqid_ds *buf);
    • 常用命令
      • IPC_STAT:获取队列状态(存于buf)​
      • IPC_SET:设置队列属性(从buf读取)​
      • IPC_RMID:标记队列待删除(立即生效,资源在最后一次引用时释放)

2.3 典型应用示例:任务分发系统

其实就是一个生产者消费者模型,这里就不仔细介绍了,感兴趣的自己先去了解,后面其他的文章中再讲。

// 生产者进程:发送任务消息
struct task_msg {long mtype;       // 任务类型(1=计算任务,2=IO任务)int task_id;      // 任务IDchar data[512];   // 任务数据
};int main() {key_t key = ftok("taskq", 'T');int msqid = msgget(key, 0660 | IPC_CREAT);struct task_msg msg = {.mtype=1, .task_id=1001};strcpy(msg.data, "complex calculation");if (msgsnd(msqid, &msg, sizeof(msg)-sizeof(long), 0) < 0) {perror("msgsnd failed");}return 0;
}// 消费者进程:处理计算任务
int main() {key_t key = ftok("taskq", 'T');int msqid = msgget(key, 0660);struct task_msg msg;while(1) {msgrcv(msqid, &msg, sizeof(msg)-sizeof(long), 1, 0); // 只接收类型1process_task(msg.task_id, msg.data);}return 0;
}

2.4 管理与调优​

  • 查看队列ipcs -q(显示msqid、权限、消息数等)​
  • 删除队列ipcrm -q <msqid> 或程序中msgctl(msqid, IPC_RMID, NULL)​
  • 系统限制(通过/proc/sys/kernel配置):​
    • msgmax:单个消息最大字节数(默认 8192)​
    • msgmnb:单个队列最大字节数(默认 16384)​
    • msgmni:系统最大队列数(默认 16)

三、System V 信号量:

3.1 核心概念:信号量集​

信号量本质是一个整数计数器,用于控制对共享资源的访问。System V 实现支持信号量集(多个信号量的集合),主要用于:​

  • 互斥锁:确保资源一次仅被一个进程访问(二元信号量)​
  • 资源池:控制并发访问的进程数量(计数信号量)​
  • 进程同步:协调进程执行顺序(如子进程完成初始化后通知父进程)

3.2 关键系统调用

  1. semget:创建 / 获取信号量集
    • int semget(key_t key, int nsems, int semflg);
    • nsems:信号量集中的信号量数量(如 1 表示单个信号量)​
    • semflg:与msgget类似,支持IPC_CREAT/IPC_EXCL及权限设置​
    • 返回值:信号量集 ID(semid),失败返回 -1
  2. semop:执行信号量操作(PV 操作
    • int semop(int semid, struct sembuf *ops, unsigned short nops);
    • sembuf 结构体定义操作细节:
      struct sembuf 
      {unsigned short sem_num;  // 信号量在集合中的索引(0开始)short          sem_op;   // 操作值://  +n:释放n个资源(V操作)//  -n:申请n个资源(P操作,阻塞直到可用)//  0:等待信号量为0short          sem_flg;  // 标志位:// IPC_NOWAIT:非阻塞操作// SEM_UNDO:进程异常终止时自动恢复信号量
      };
      
    • 示例:单个信号量的 P 操作(申请资源)
      struct sembuf p_op = {0, -1, 0}; // 操作第一个信号量,减1,阻塞
      semop(semid, &p_op, 1);
      
  3. semctl:控制信号量集
    • int semctl(int semid, int semnum, int cmd, ...);
    • 常用命令:​
      • SETVAL:初始化信号量值(需union semun参数)
      union semun {int              val;    // 用于SETVALstruct semid_ds *buf;    // 用于IPC_STAT/IPC_SETunsigned short  *array;  // 用于GETALL/SETALL
      };
      semctl(semid, 0, SETVAL, (union semun){.val=1}); // 初始化第一个信号量为1(互斥锁)
      
      • GETVAL:获取信号量当前值​
      • IPC_RMID:删除信号量集

3.3 互斥锁实现示例

// 初始化信号量为1(互斥锁)
int init_semaphore(key_t key) {int semid = semget(key, 1, 0660 | IPC_CREAT | IPC_EXCL);union semun arg = {.val=1};semctl(semid, 0, SETVAL, arg);return semid;
}// P操作(加锁)
void sem_lock(int semid) {struct sembuf op = {0, -1, 0};semop(semid, &op, 1);
}// V操作(解锁)
void sem_unlock(int semid) {struct sembuf op = {0, +1, 0};semop(semid, &op, 1);
}// 共享资源访问示例
int shared_data = 0;int main() {key_t key = ftok("semtest", 'S');int semid = init_semaphore(key);if (fork() == 0) { // 子进程sem_lock(semid);shared_data += 100;sem_unlock(semid);} else { // 父进程sem_lock(semid);shared_data += 200;sem_unlock(semid);wait(NULL);printf("Shared data: %d\n", shared_data); // 输出300}semctl(semid, 0, IPC_RMID); // 删除信号量集return 0;
}

3.4 使用中的陷阱

  • SEM_UNDO 标志:始终为semop设置该标志,避免进程崩溃导致信号量未释放(僵尸锁)​
  • 原子操作semop支持一次操作多个信号量,确保操作原子性(如同时获取多个资源)​
  • 管理命令:​
    • ipcs -s:查看信号量集​
    • ipcrm -s <semid>:删除信号量集​
  • 性能注意:信号量本身是轻量级同步工具,但过度使用会导致频繁系统调用

四、三种 System V IPC 机制对比

特性共享内存消息队列信号量
通信方式共享存储区带类型消息计数器同步
数据拷贝无(内核空间共享)有(用户 <-> 内核)无(仅状态控制)
同步支持需额外同步机制异步通信内置同步原语
类型支持消息类型过滤
生命周期显式删除 / 系统重启显式删除 / 系统重启显式删除 / 系统重启
典型场景大数据量高频交互异步消息传递资源互斥 / 同步
核心 APIshmget/shmat/shmdtmsgget/msgsnd/msgrcvsemget/semop/semctl
系统限制配置shmmax/shmallmsgmax/msgmnbsemmax/semmni

五、总结​

System V IPC 三剑客各有所长:​

  • 共享内存适合高性能数据交互,但需手动处理同步​
  • 消息队列提供类型化异步通信,适合解耦的生产者 - 消费者模型​
  • 信号量专注于进程同步与资源控制,是构建复杂同步逻辑的基石​

实际应用中,三者常结合使用(如共享内存 + 信号量实现高效安全的共享数据访问)。使用时需注意:​

  1. 始终检查 API 调用错误(IPC 机制易受资源限制影响)​
  2. 显式释放资源(避免系统重启前的内存 / 队列泄漏)​
  3. 根据场景选择合适的 IPC 组合(而非单一机制)​

通过合理运用这三种机制,可以构建稳定高效的进程间通信架构,满足不同复杂度的分布式系统需求。

相关文章:

  • ABP vNext + Hive 集成:多租户大数据 SQL 查询与报表分析
  • 到院率最高提升40%,消费医疗用AI营销机器人跑赢增长焦虑
  • MySQL中event突然不执行问题分析
  • C++ 8.1 内联函数
  • 如何使用 DeepSeek 帮助自己的工作
  • 深入解析MySQL锁机制:从全局锁到行级锁的全面指南
  • Uniapp如何适配HarmonyOS5?条件编译指南以及常见的错误有哪些?
  • DAY47打卡
  • 常见算法题目6 - 给定一个字符串,输出其最长的回文子串
  • 多场景 OkHttpClient 管理器 - Android 网络通信解决方案
  • 用户体验升级:表单失焦调用接口验证,错误信息即时可视化
  • 111页可编辑精品PPT | 华为业务变革框架及战略级项目管理华为数字化转型方法论
  • 不同类型的道路运输安全员证书(如公路、水路、联运)考试内容有何区别?
  • 力扣LFU460
  • VAE(变分自编码器) CVAE(条件变分自编码器)
  • 第二篇:Agent2Agent (A2A) 协议——A2A 架构、组件和通信动态
  • C++基础学习:深入理解类中的构造函数、析构函数、this指针与new关键字
  • java复习 07
  • Rust 学习笔记:通过 Send 和 Sync trait 实现可扩展并发性
  • C++11列表初始化:从入门到精通
  • 保山网站建设优化/百度指数怎么查
  • 云南网站开发报价/seo名词解释
  • kuler网站/地推放单平台
  • 网站做301的坏处/太原百度网站快速排名
  • 网站建设的公司实习做什么/潍坊百度关键词优化
  • 家政网站建设/成年学校培训班