IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 系统运维 -> Linux多进程编程--结合网络Socket编程 -> 正文阅读

[系统运维]Linux多进程编程--结合网络Socket编程


注:本文系湛江市岭南师范学院物联网俱乐部原创部分训练计划,转载请保留声明。

前言

今天是闭关的第21天,今晚做了一下Linux下的多进程编程,并且顺便实现了服务器端同时监听多个客户端的编程,接下里将带着大家一起学习。

一、进程

1.1 进程的定义

《计算机操作系统》这门课对进程有这样的描述:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

1.2 进程的概念

进程的概念主要有两点:

第一:进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。

第二:进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。

第三:进程是操作系统中最基本、重要的概念。是多道程序系统出现后,为了刻画系统内部出现的动态情况,描述系统内部各道程序的活动规律引进的一个概念,所有多道程序设计操作系统都建立在进程的基础上。

第四:操作系统引入进程的概念的原因

从理论角度看,是对正在运行的程序过程的抽象;

从实现角度看,是一种数据结构,目的在于清晰地刻划动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。

1.3 进程的切换

主要特征:
①动态性:进程的实质是程序在多道程序系统中的一次执行过程,进程是动态产生,动态消亡的。

②并发性:任何进程都可以同其他进程一起并发执行

③独立性:进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位;

④异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进

⑤结构特征:进程由程序、数据和进程控制块三部分组成。

多个不同的进程可以包含相同的程序:一个程序在不同的数据集里就构成不同的进程,能得到不同的结果;但是执行过程中,程序不能发生改变。

1.4 进程的结构

?进行进程切换就是从正在运行的进程中收回处理器,然后再使待运行进程来占用处理器。

?这里所说的从某个进程收回处理器,实质上就是把进程存放在处理器的寄存器中的中间数据找个地方存起来,从而把处理器的寄存器腾出来让其他进程使用。那么被中止运行进程的中间数据存在何处好呢?当然这个地方应该是进程的私有堆栈。

?让进程来占用处理器,实质上是把某个进程存放在私有堆栈中寄存器的数据(前一次本进程被中止时的中间数据)再恢复到处理器的寄存器中去,并把待运行进程的断点送入处理器的程序指针PC,于是待运行进程就开始被处理器运行了,也就是这个进程已经占有处理器的使用权了。

?在切换时,一个进程存储在处理器各寄存器中的中间数据叫做进程的上下文,所以进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。在进程未占用处理器时,进程的上下文是存储在进程的私有堆栈中的。

二、多线程编程

??什么是一个进程?在操作系统原理使用这样的术语来描述的:正在运行的程序及其占用的资源(CPU、内存、系统资源等)叫做进程。站在程序员的角度来看,我们使用vim编辑生成的C文件叫做源码,源码给程序员来看的但机器不识别,这时我们需要使用编译器gcc编译生成CPU可识别的二进制可执行程序并保存在存储介质上,这时编译生成的可执行程序只能叫做程序而不能叫进程。而一旦我们通过命令(./a.out)开始运行时,那正在运行的这个程序及其占用的资源就叫做进程了。进程这个概念是针对系统而不是针对用户的,对用户来说,他面对的概念是程序。很显然,一个程序可以执行多次,这也意味着多个进程可以执行同一个程序。

2.1进程空间内容布局

??在深入理解Linux下多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。Linux 进程内存管理的对象都是虚拟内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址,并不是实际的物理内存地址。 Linux下一个进程在内存里有三部分的数据,就是”代码段”、”堆栈段”和”数据段”。其实学过汇编语言的人一定知道,一般的CPU都有上述三种段寄存器,以方便操作系统的运行。这三个部分是构成一个完整的执行序列的必要的部分。”代码段”,顾名思义,就是存放了程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用相同的代码段。”堆栈段”存放的就是子程 序的返回地址、子程序的参数以及程序的局部变量和malloc()动态申请内存的地址。而数据段则存放程序的全局变量,静态变量及常量的内存空间。

下图是Linux下进程的内存布局:
在这里插入图片描述
①栈。栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用
都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中,
函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。

②堆。堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。

③非初始化数据段。通常将此段称为 bss 段,用来存放未初始化的全局变量和 static 静态变量。并且在程序开始执行之前,就是在 main()之前,内核会将此段中的数据初始化为 0 或空指针。

④初始化数据段。用来保已初始化的全局变量和 static 静态变量。

⑤文本段也称代码段,这是可执行文件中由 CPU 执行的机器指令部分。正文段常常是只读的,以防止程序由于意外而修改其自身的执行。

?Linux 内存管理的基本思想就是只有在真正访问一个地址的时候才建立这个地址的物理映射,Linux C/C++语言的分配方式共有3 种方式。

(1)从静态存储区域分配。就是数据段的内存分配,这段内存在程序编译阶段就已经分配好,在程序的整个运行期间都存在,例如全局变量,static 变量。

(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是系统栈中分配的内存容量有限,比如大额数组就会把栈空间撑爆导致段错误。

(3)从堆上分配,亦称动态内存分配。程序在运行的时候用 malloc 或 new 申请任意多少的内存,程序员自己负责在何时用free 或 delete 释放内存。此 区域内存分配称之为动态内存分配。动态内存的生存期由我们决定,使用非常灵活,但问题也最多,比如指向某个内存块的指针取值发生了变化又没有其他指针指向 这块内存,这块内存就无法访问,发生内存泄露。

2.2 fork系统调用

?Linux内核在启动的最后阶段会创建init进程来执行程序/sbin/init,该进程是系统运行的第一个进程,进程号为 1,称为
Linux 系统的初始化进程,该进程会创建其他子进程来启动不同写系统服务,而每个服务又可能创建不同的子进程来执行不同的
程序。所以init进程是所有其他进程的“祖先”,并且它是由Linux内核创建并以root的权限运行,并不能被杀死。Linux 中维护
着一个数据结构叫做 进程表,保存当前加载在内存中的所有进程的有关信息,其中包括进程的 PID(Process ID)、进程的状态、
命令字符串等,操作系统通过进程的 PID 对它们进行管理,这些 PID 是进程表的索引。

?Linux下有两个基本的系统调用可以用于创建子进程:fork()vfork()。fork在英文中是"分叉"的意思。为什么取这个名字呢?因为一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就”分叉”了,所以这个名字取得很形象。在我们编程的过程中,一个函数调用只有一次返(return),但由于fork()系统调用会创建一个新的进程,这时它会有两次返回。一次返回
是给父进程,其返回值是子进程的PID(Process ID),第二次返回是给子进程,其返回值为0。所以我们在调用fork()后,需要通过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进程在运行,而如果返回值<0的话,说明fork()系统调用出错。fork 函数调用失败的原因主要有两个:

? ?1. 系统中已经有太多的进 程;
? ?2. 该实际用户 ID 的进程总数超过了系统限制。

?每个子进程只有一个父进程,并且每个进程都可以通过getpid()获取自己的进程PID,也可以通过getppid()获取父进程的PID,这样在fork()时返回0给子进程是可取的。一个进程可以创建多个子进程,这样对于父进程而言,他并没有一个API函数可以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式告诉父进程其创建的子进程PID。这也是fork()系统调用两次返回值设计的原因。
在这里插入图片描述

2.3 子进程继承父进程哪些东西

从上面的例子中我们可以知道,知道子进程从父进程那里继承什么或未继承什么将有助于我们今后的编程。下面这个名单会因为 不同Unix的实现而发生变化,所以或许准确性有了水份。请注意子进程得到的是 这些东西的 拷贝,不是它们本身。 由子进程自父进程继承到:
在这里插入图片描述
在这里插入图片描述

2.4 exec*()执行另外一个程序

?说是exec系统调用 ,实际上在Linux中,并不存在一个exec()的函数形式,exec指的是一组函数,一共有6个,分别是:

#include <unistd.h>

extern char **environ;

int execl(const char *path, const char *arg, ...);

int execlp(const char *file, const char *arg, ...);

int execle(const char *path, const char *arg, ..., char *const envp[]);

int execv(const char *path, char *const argv[]);

int execvp(const char *file, char *const argv[]);

int execve(const char *path, char *const argv[], char *const envp[]);

?其中只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。

?exec函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。

?与 一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID等一些表 面上的信息仍保持原样,颇有些神似"三十六计"中的"金蝉脱壳"。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从 原程序的调用点接着往下执行。

?现在我们应该明白了,Linux下是如何执行新程序的,每当有进程认为自己不能为系统和用户做出任何 贡献了,他就可以发挥最后一点余热,调用任何一个exec,让自己以新的面貌重生;或者,更普遍的情况是,如果一个进程想执行另一个程序,它就可以 fork出一个新进程,然后调用任何一个exec,这样看起来就好像通过执行应用程序而产生了一个新进程一样。

?事实 上第二种情况被应用得如此普遍,以至于Linux专门为其作了优化,我们已经知道,fork会将调用进程的所有内容原封不动的拷贝到新产生的子进程中去, 这些拷贝的动作很消耗时间,而如果fork完之后我们马上就调用exec,这些辛辛苦苦拷贝来的东西又会被立刻抹掉,这看起来非常不划算,于是人们设计了 一种"写时拷贝(copy-on-write)"技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句 是exec,它就不会白白作无用功了,也就提高了效率。

?返回值,如果执行成功则函数不会返回,执行失败则直接返回-1,失败原因存于errno 中。

2.5 vfork系统调用

?在fork()之后常会紧跟着调用exec来执行另外一个程序,而exec会抛弃父进程的文本段、数据段和堆栈等并加载另外一个程序,所以现在的很多fork()实现并不执行一个父进程数据段、堆和栈的完全副本拷贝。作为替代,
使用了写时复制(CopyOnWrite)技术: 这些数据区域由父子进程共享,内核将他们的访问权限改成只读,如果父进程和子进程
中的任何一个试图修改这些区域的时候,内核再为修改区域的那块内存制作一个副本。
?vfork()是另外一个可以用来创建进程的函数,他与fork()的用法相同,也用于创建一个新进程。 但vfork()并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit(),于是也就不会引用该地址空间了。不过子进程再调用exec()或exit()之前,他将在父进程的空间中运行,但如果子进程想尝试修改数据域(数据段、堆、栈)都会带来未知的结果,因为他会影响了父进程空间的数据可能会导致父进程的执行异常。此外,vfork()会保证子进程先运行,在他调用了exec或exit()之后父进程才可能被调度运行。如果子进程依赖于父进程的进一步动作,则会导致死锁。

2.2 wait与waitpd

参考链接:https://blog.csdn.net/yiyi__baby/article/details/45539993

三、多进程编写服务器程序

3.1 源码展示

①版本一,不带处理“僵尸进程”

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>

#define LISTEN_PORT 8083
#define BACKLOG 13

int main(int argc, char **argv)
{
   int rv = -1;
   int listen_fd, client_fd = -1;
   struct sockaddr_in serv_addr;
   struct sockaddr_in cli_addr;
   socklen_t cliadd_len;
   char buff[1024];
   pid_t pid;

   listen_fd = socket(AF_INET, SOCK_STREAM, 0);
   if (listen_fd < 0)
   {
      printf("create socket failure:%s\n", strerror(errno));
      printf("listen_fd:%d\n", listen_fd);
      return -1;
   }
   printf("create socket success!\n");
   printf("socket create fd[%d]\n", listen_fd);
   printf("\n\n");

   memset(&serv_addr, 0, sizeof(serv_addr));
   serv_addr.sin_family = AF_INET;
   serv_addr.sin_port = htons(LISTEN_PORT);
   serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

   if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
   {
      printf("bind socket failure:%s\n", strerror(errno));
      return -2;
   }
   printf("bind socket success!\n");
   printf("socket[%d] bind on port[%d] for all ip address ok\n", listen_fd, LISTEN_PORT);
   printf("\n\n");

   listen(listen_fd, BACKLOG);

   while (1)
   {

      printf("\nStart waiting and accept new client connect..\n");
      client_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cliadd_len);
      if (client_fd < 0)
      {
         printf("accept new socket failure:%s\n", strerror(errno));
         return -2;
      }
      printf("accept new socket success!\n");
      printf("Accept new client[%s:%d] with fd[%d]\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), client_fd);
      printf("\n\n");

      pid = fork();
      if (pid < 0)
      {
         printf("fork() create child process failure:%s\n", strerror(errno));
         close(client_fd);
         continue;
      }
      else if (pid > 0)
      {
         close(client_fd);
         printf("Parent process runing, its pid:%d\n", getpid());
         printf("retrun pid is Child process pid:%d\n", pid);
         printf("\n");
         continue;
      }
      else if (pid == 0)
      {
         printf("Parent process pid     is:%d\n", getppid());
         printf("Child  process running pid:%d\n", getpid());
         printf("\n");
         close(listen_fd);
         while (1)
         {
            memset(buff, 0, sizeof(buff));
            rv = read(client_fd, buff, sizeof(buff));
            if (rv < 0)
            {
               printf("Read data from client socket[%d] failure:%s\n", client_fd, strerror(errno));
               printf("\n\n");
               close(client_fd);
               exit(0);
            }
            else if (rv == 0)
            {
               printf("client socket[%d] disconect\n", client_fd);
               printf("\n\n");
               close(client_fd);
               exit(0);
            }
            printf("Read %d bytes data from client[%d] and echo it back: '%s'\n", rv, client_fd, buff);
            printf("\n\n");

            if (write(client_fd, buff, rv) < 0)
            {
               printf("write %d bytes data back to client[%d] failure:%s\n", rv, client_fd, strerror(errno));
               printf("\n\n");
               close(client_fd);
               exit(0);
            }
            printf("write %d bytes data back to client[%d]\n", rv, client_fd);
            printf("\n\n");

            sleep(1);
            // close(client_fd);
         }
      }
   }
   close(listen_fd);
}

②版本二,自带处理“僵尸编程”

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>

#define LISTEN_PORT 8083
#define BACKLOG 13

void sig_chld(int signo);

int main(int argc, char **argv)
{
  int rv = -1;
  int listen_fd, client_fd = -1;
  struct sockaddr_in serv_addr;
  struct sockaddr_in cli_addr;
  socklen_t cliadd_len;
  char buff[1024];
  pid_t pid;

  signal(SIGCHLD, sig_chld); //处理僵死进程

  listen_fd = socket(AF_INET, SOCK_STREAM, 0);
  if (listen_fd < 0)
  {
    printf("create socket failure:%s\n", strerror(errno));
    printf("listen_fd:%d\n", listen_fd);
    return -1;
  }
  printf("create socket success!\n");
  printf("socket create fd[%d]\n", listen_fd);
  printf("\n\n");

  memset(&serv_addr, 0, sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_port = htons(LISTEN_PORT);
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

  if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
  {
    printf("bind socket failure:%s\n", strerror(errno));
    return -2;
  }
  printf("bind socket success!\n");
  printf("socket[%d] bind on port[%d] for all ip address ok\n", listen_fd, LISTEN_PORT);
  printf("\n\n");

  listen(listen_fd, BACKLOG);

  while (1)
  {

    printf("\nStart waiting and accept new client connect..\n");
    client_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &cliadd_len);
    if (client_fd < 0)
    {
      printf("accept new socket failure:%s\n", strerror(errno));
      return -2;
    }
    printf("accept new socket success!\n");
    printf("Accept new client[%s:%d] with fd[%d]\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), client_fd);
    printf("\n\n");

    pid = fork();
    if (pid < 0)
    {
      printf("fork() create child process failure:%s\n", strerror(errno));
      close(client_fd);
      continue;
    }
    else if (pid > 0)
    {
      close(client_fd);
      printf("Parent process runing, its pid:%d\n", getpid());
      printf("retrun pid is Child process pid:%d\n", pid);
      printf("\n");
      continue;
    }
    else if (pid == 0)
    {
      printf("Parent process pid     is:%d\n", getppid());
      printf("Child  process running pid:%d\n", getpid());
      printf("\n");
      close(listen_fd);
      while (1)
      {
        memset(buff, 0, sizeof(buff));
        rv = read(client_fd, buff, sizeof(buff));
        if (rv < 0)
        {
          printf("Read data from client socket[%d] failure:%s\n", client_fd, strerror(errno));
          printf("\n\n");
          close(client_fd);
          exit(0);
        }
        else if (rv == 0)
        {
          printf("client socket[%d] disconect\n", client_fd);
          printf("\n\n");
          close(client_fd);
          exit(0);
        }
        printf("Read %d bytes data from client[%d] and echo it back: '%s'\n", rv, client_fd, buff);
        printf("\n\n");

        if (write(client_fd, buff, rv) < 0)
        {
          printf("write %d bytes data back to client[%d] failure:%s\n", rv, client_fd, strerror(errno));
          printf("\n\n");
          close(client_fd);
          exit(0);
        }
        printf("write %d bytes data back to client[%d]\n", rv, client_fd);
        printf("\n\n");

        sleep(1);
        // close(client_fd);
      }
    }
  }
  close(listen_fd);
}

void sig_chld(int signo)
{
  pid_t pid;
  for (;;)
  {
    pid = waitpid(-1, NULL, WNOHANG);
    if (pid < 0)
    {
      break;
    }
  }
}

3.2 效果图展示

(1)版本一,不带处理“僵尸进程”,编程运行效果图
①客户端连接服务器截面图:

在这里插入图片描述
②服务器端被客户端连接后,打印父进程PID、客户端1号PID和客户端2好PID,效果图如下:
在这里插入图片描述
③客户端发消息给服务器端,客户端效果图
在这里插入图片描述
④客户端发消息给服务器端,服务器端效果图
在这里插入图片描述

(2)版本二,自带处理“僵尸编程”,编程运行效果图
①基本效果图和版本一是一样的,版本二的效果图重点突出杀进程。
②查看版本一服务器端的进程正常运行情况,效果图:
在这里插入图片描述
③查看版本一服务器端的进程非正常运行情况(客户端1断开连接,“僵尸进程”出现),效果图:
在这里插入图片描述
④查看版本二服务器端的进程正常运行情况,效果图:
在这里插入图片描述
⑤查看版本二服务器端的进程非正常运行情况(客户端1断开连接,“僵尸进程”出现,并杀死该进程),效果图:
在这里插入图片描述

四、总结

Linux多进程编程是非常重要的一门课程,上一篇博客实现网络Socket的时候客户端与服务器实现了通讯,但是只是实现一对一,这在现实环境是不现实的,所以增加了多进程,就能实现多个客户端对服务器端,从而让服务器具有高并发的能力。

  系统运维 最新文章
配置小型公司网络WLAN基本业务(AC通过三层
如何在交付运维过程中建立风险底线意识,提
快速传输大文件,怎么通过网络传大文件给对
从游戏服务端角度分析移动同步(状态同步)
MySQL使用MyCat实现分库分表
如何用DWDM射频光纤技术实现200公里外的站点
国内顺畅下载k8s.gcr.io的镜像
自动化测试appium
ctfshow ssrf
Linux操作系统学习之实用指令(Centos7/8均
上一篇文章      下一篇文章      查看所有文章
加:2021-07-22 14:38:04  更:2021-07-22 14:40:08 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/3 15:00:52-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码