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

[Linux](16)网络编程:网络概述,网络基本原理,套接字,UDP,TCP,并发服务器编程,守护(精灵)进程

文章目录

  • 网络协议初识
  • OSI 七层模型
  • TCP/IP 四层(或五层)模型
  • IP、MAC、端口号
  • TCP 协议与 UDP 协议
  • 套接字
    • 套接字地址结构
    • socket 函数
    • bind 函数
    • recvfrom 函数
    • sendto 函数
  • UDP 通信实现
    • 服务端
    • 客户端
  • TCP 通信实现
    • 服务端
      • listen 函数
      • accept 函数
      • 实现
    • 客户端
      • connect 函数
      • 实现
    • 改进:并发服务器
      • **改进1**:多进程版本
      • **改进2**:使用线程
      • **改进3**:使用线程池
  • 守护进程(精灵进程)

网络协议初识

协议,就是一种约定,人与人之间通过自然语言这种约定来进行交流,计算机之间通过光电信号的交流,同样需要协议来约定数据格式。

例如打电话,张三和李四通过汉语协议来进行交流,张三的电话机将张三的声音转换成某种信号发到李四的电话机,李四的电话机必须与张三的电话机约定好某种协议,才能将收到的数据正确地解释为汉语来服务于李四。

从上面的例子中,我们可以发现,这种协议具有层状结构,同层协议都可以认为自己和对方是直接通信。比如张三与李四肯定认为它们是直接交流的,因为他们不会在乎底层的电话机是怎么工作的。电话机和电话机之间也不会在乎张三和李四在说什么。

上面的例子只有两层,而实际的网络协议更复杂,需要分更多的层次。不变的一点是,层状结构下的网络协议,同层之间有着自己的协议,同层之间都可以认为是直接在和对方进行通信,忽略底层细节。

OSI 七层模型

OSI 即 Open System Interconnection,开放式系统互联。是一个逻辑上的定义和规范。

OSI 模型把网络从逻辑上分为了 7 层,从下到上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

它的最大优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚,理论也比较完整. 通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。

但是, 它分层太多,既复杂又不实用。所以我们按照 TCP/IP 四层模型来讲解。

img

TCP/IP 四层(或五层)模型

每台因特网主机都运行实现 TCP/IP 协议(Transmission Control Protocol/Internet Protocol,传输控制协议/互联网络协议)的软件,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合式使用套接字接口函数和 Unix I/O 函数来进行通信。通常将套接字函数实现为系统调用,这些系统调用会陷入内核,并调用各种内核模式的 TCP/IP 函数。

TCP/IP 实际是一个协议族,其中每一个都提供不同的功能。例如,IP 协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫作数据报(datagram)。

TCP/IP通讯协议采用了5层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求。物理层我们考虑得比较少,因此也可以叫做 TCP/IP 四层模型。

  • 物理层:负责光/电信号的传递方式。比如现在以太网通用的网线(双绞线)、早期以太网采用的的同轴电缆(现在主要用于有线电视)、光纤,现在的 wifi 无线网使用电磁波等都属于物理层的概念。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)工作在物理层。
  • 数据链路层:负责设备之间的数据帧的传送和识别。例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。有以太网、令牌环网,无线 LAN 等标准。交换机(Switch)工作在数据链路层。
  • 网络层:负责地址管理和路由选择。例如在 IP 协议中,通过 IP 地址来标识一台主机,并通过路由表的方式规划出两台主机之间的数据传输的线路(路由)。路由器(Router)工作在网路层。传输层:负责两台主机之间的数据传输。如传输控制协议(TCP)。能够确保数据可靠的从源主机发送到目标主机。
  • 应用层:负责应用程序间沟通,如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等。我们的网络编程主要就是针对应用层。

为什么要分层呢?

其实分层的最大好处在于封装

我们一般都是通过应用层来访问网络,应用层的程序产生的数据会自上而下地传输,直到最后的网络接口层,然后通过网线发送到互联网。数据每往下走一层,就会被这一层的协议增加一层包装,等到发送到互联网上时,已经比原始数据多了四层包装。

当另一台计算机接收到数据包时,会从网络接口层再一层一层往上传输,每传输一层就拆开一层包装,直到最后的应用层,就得到了最原始的数据,这才是程序要使用的数据。这个过程就是解包。

给数据增加包装,就是在数据的头部增加标志,这个标志叫做包头。解包则是去掉包头,展现出原始数据。

  • 不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。
  • 应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation)。
  • 首部信息中包含了一些类似于首部有多长,载荷(payload)有多长,上层协议是什么等信息。
  • 数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,根据首部中的“上层协议字段”将数据交给对应的上层协议处理。

IP、MAC、端口号

IP 地址

一台计算机可以拥有一个独立的 IP 地址,一个局域网也可以拥有一个独立的 IP 地址(对外展现为一台计算机)。对于目前广泛使用的 IPv4,一台计算机一个 IP 地址是不现实的,往往是一个局域网一个 IP 地址。

在因特网上进行通信时,必须要知道对方的 IP 地址。数据包中已经附带了 IP 地址,把数据包发送给路由器以后,路由器会根据 IP 地址找到对方的地理位置,完成一次数据的传递。路由器有非常高效和智能的算法,很快就会找到目标计算机。

一个 IP 地址就是一个 32 位无符号整数。

IPv4 和 IPv6

IPv4 即因特网协议版本4(Internet Protocol Version 4,IPv4)。是最初的因特网协议,使用 32 位地址。

IPv6 即因特网协议版本6,它使用的是 128 位地址,意在替代 IPv4,但是到目前,大部分软件还是使用的 IPv4。

IP 地址通常以 点分十进制 表示法来表示,即,每一个字节都用它的十进制值来表示,中间用句点分隔。如,128.2.194.242 就是地址 0x8002c2f2 的点分十进制表示。

应用程序使用 inet_ptoninet_ntop 函数来实现 IP 地址和点分十进制串之间的转换。

#include <arpa/inet.h>

int inet_pton(AF_INET, const char *src, void *dst);
// 返回:若成功则为1,若 src 为非法点分十进制地址则为0,若出错则为-1,并设置errno
// 将一个点分十进制串(src)转换为一个二进制的网络字节顺序的 IP 地址(dst)
const char *inet_ntop(AF_INET, const void *src, char *dst,
                     socklen_t size);
// 返回:若成功则为指向点分十进制字符串的指针,若出错则为 NULL
// 将一个二进制的网络字节顺序的 IP 地址(src)转换为它所对应的点分十进制表示,并把得到的以 null 结尾的字符串的最多 size 个字节复制到 dst

在这些函数名中,“n“代表网络,”p“代表表示,你可以传入 AF_INET,处理 32 位的 IPv4 地址,就像上面展示的一样。也可以传入 AF_INET6,处理 128 位 IPv6 地址。

因为因特网主机可以有不同的主机字节顺序,TCP/IP 为任意整数数据项定义了统一的网络字节顺序,即大端字节顺序。例如 IP 地址,它放在包头中,跨过网络被携带。在 IP 地址结构中存放的地址总是以(大端法)网络字节顺序存放的,即使主机字节顺序是小端法。

Unix 提供了以下函数在网络和主机字节顺序间实现转换。

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
// 返回:按照网络字节顺序的值
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
// 返回:按照主机字节顺序的值

另外,使用 inet_addr 可以直接将点分十进制 IP 转换网络字节序 IP

in_addr_t inet_addr(const char *cp);

使用 inet_ntoa 可以将网络字节序 IP 转换为点分十进制 IP

char *inet_ntoa(struct in_addr in);

特殊 IP 地址

127.0.0.1

127.0.0.1 是主机环回地址,向 127.0.0.1 发送的数据包即使到达协议栈底部也不会发送到互联网上,而是被它自己“环回”,让发送数据包的计算机自己成为接收者。简单来说就是自己发给自己。


MAC 地址

上文说到,实际上往往是一个局域网一个 IP 地址,并没有办法定位到某一台计算机。

而 MAC 地址(Media Access Control Address)才是真正唯一标识计算机的。每台计算机的网卡在出厂时就已经写死了它的 MAC 地址,MAC 地址是全球唯一的。

数据包中除了有 IP 地址,也有 MAC 地址,当数据包到达局域网后,路由器会根据 MAC 地址找到对应的计算机,将数据包交给它。


端口号

找到了某一台计算机还不行,我们的数据要交给的应该是计算机中的某一个程序来处理。

为了区分不同的网络程序,计算机会为每个网络程序分配端口号(Port Number)

  • 端口号是一个 2 字节 16 位的整数
  • 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
  • IP 地址 + 端口号能够标识网络上的某一台主机的某一个进程
  • 一个端口号只能被一个进程占用

所以网络通信,其本质其实也是进程间通信,只是地理位置长了些。


一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个 IP 地址和一个 16 位的整数端口组成的。用”地址 : 端口“来表示。

一个连接是由它两端的套接字地址唯一确定的。这对套接字叫做 套接字对(socket pair),由以下元祖表示:

(cliaddr:cliport, servaddr:servport)

关于套接字的细节,我们在下面接着讲。

TCP 协议与 UDP 协议

此处我们简单了解一下,后续我们再详细讨论细节问题。

TCP

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

UDP:

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

简单来说 TCP 传输数据稳定可靠,适用于对网络通讯质量要求较高的场景,需要准确无误的传输给对方。比如,传输文件,发送邮件,浏览网页等等

UDP 的优点是速度快,但是可能产生丢包,所以适用于对实时性要求较高但是对少量丢包并没有太大要求的场景。比如:QQ聊天、语音通话,视频直播等。

套接字

从 Linux 内核的角度来看,一个套接字就是通信的一个端点。从 Linux 程序的角度来看,套接字就是一个有相应描述符的打开文件。

套接字接口(socket interface)是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用。大多数现代系统上都实现了套接字接口,包括所有的 Unix 变种、Windows 和 Macintosh 系统。

套接字地址结构

因特网的套接字地址存放在类型为 sockaddr_in 的 16 字节结构中(_in 后缀是互联网络(Internet)的缩写)。对于因特网应用,sin_family 成员是 AF_INETsin_port 成员是一个 16 位的端口号,sin_addr 成员是一个 32 位的 IP 地址。IP 地址和端口号总是以网络字节顺序(大端法)存放的。

struct sockaddr_in
{
    uint16_t sin_family;
    uint16_t sin_port;
    struct in_addr sin_addr;
    unsigned char sin_zero[8];
};

struct sockaddr
{
    uint16_t sa_family;
    char sa_data[14];
};

套接字接口 connectbindaccept 函数要求一个指向与协议相关的套接字地址结构的指针。套接字接口的设计者面临的问题是,如何定义这些函数,使之能够接受各种类型的套接字地址结构。今天我们知道可以使用 void* 指针,但在那时 C 中并不存在这种类型的指针。解决办法是定义套接字函数要求一个指向通用 sockaddr 结构的指针,然后要求应用程序将与协议特定的结构的指针强制转换成这个通用结构。

socket 函数

客户端和服务器使用 socket 函数来创建一个套接字描述符(socket descriptor)

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

int socket(int domain, int type, int protocol);
// 返回:若成功则为非负描述符,若出错则为-1

domain: 套接字的域,也就是IP地址类型,我们一般传入 AF_INET,表示正在使用 32 位 IP 地址。AF_INET 也可以写成 PF_INET,这两个是一样的。

type: 套接字类型,常用的有两种:SOCK_STREAM(流格式套接字,基于 TCP 协议) 和 SOCK_DGRAM(数据报格式套接字,基于 UDP 协议)。

protocol: 协议类型,在网络应用中直接设为 0 即可。

bind 函数

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr,
        socklen_t addrlen);
// 返回:若成功则为0,若出错则为-1

bind 函数告诉内核将 addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来。参数 addrlen 就是 sizeof(sockaddr_in)

recvfrom 函数

通过套接字 sockfdlen 个字节读入 buf

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
// 返回:若成功则为读到的字节数,若出错则为-1

flags: 设置为0,阻塞式读取

src_addr: 输出型参数,返回客户端信息

addrlen: 输入输出型参数,就是 sizeof(src_addr)

sendto 函数

通过套接字 sockfdbuflen 个字节进行发送

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
              const struct sockaddr *dest_addr, socklen_t addrlen);

注意,addrlen 参数不再是一个输出型参数。

UDP 通信实现

打印日志函数

Log.hpp

#pragma once

#include <cstdio>
#include <cstdarg>
#include <cassert>
#include <ctime>
#include <cstdlib>
#include <cstring>
#include <cerrno>

#define DEBUG 0
#define NOTICE 1
#define WARNING 2
#define FATAL 3

const char* log_level[] = {"DEBUG", "NOTICE", "WARNING", "FATAL"};

void logMessage(int level, const char* format, ...)
{
    assert(level >= DEBUG);
    assert(level <= FATAL);

    char* name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format);

    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);

    va_end(ap);

    FILE* out = level == FATAL ? stderr : stdout;
    fprintf(out, "%s | %u | %s | %s\n", log_level[level], (unsigned int)time(nullptr), getenv("USER") == nullptr ? "unknow" : name, logInfo);
}

服务端

udpServer.cc

关键步骤:创建套接字,填写网络信息,bind

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"

static void Usage(const std::string porc)
{
    std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl;
}

class UdpServer
{
public:
    UdpServer(int port, std::string ip = "")
        : ip_(ip)
        , port_((uint16_t)port)
        , sockfd_(-1)
    {}
    ~UdpServer()
    {}

    void init()
    {
        // 创建套接字
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0)
        {
            logMessage(FATAL, "%s:%d", strerror(errno), sockfd_);
            exit(1);
        }
        logMessage(DEBUG, "socket create success: %d", sockfd_);
        // 绑定网络信息,指明ip+port
        //  先填充基本信息到 struct sockaddr_in
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        // INADDR_ANY(0)表示我们不关心会bind到哪一个ip,推荐使用这种方式。
        // 另外,如果你使用的是云服务器,那么不支持指定ip进行bind
        local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
        //bind
        if (bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) == -1)
        {
            logMessage(FATAL, "%s:%d", strerror(errno), sockfd_);
            exit(2);
        }
        logMessage(DEBUG, "socket bind success: %d", sockfd_);
    }
    
    void start()
    {
        char inbuffer[1024]; // 读取到的数据
        char outbuffer[1024]; // 发送的数据
        // 服务器都设计为死循环
        while (true)
        {
            struct sockaddr_in peer; // 输出型参数
            socklen_t len = sizeof(peer); // 输入输出型参数

            ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&peer, &len);
            if (s > 0)
            {
                inbuffer[s] = 0;
            }
            else if (s == -1)
            {
                logMessage(WARNING, "recvfrom: %s:%d", strerror(errno), sockfd_);
                continue;
            }
            // 读取成功,除了读取了对方的数据,还要读取对方的网络地址[ip:port]
            std::string peerIp = inet_ntoa(peer.sin_addr);
            uint32_t peerPort = ntohs(peer.sin_port);
            // 打印客户端给服务器发送的消息
            logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer);
        }
    }

private:
    std::string ip_;
    uint16_t port_;
    int sockfd_;
};

// 命令行参数
// ./udpServer [port] [ip]
// ip 可以不填
int main(int argc, char* argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(3);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3)
    {
        ip = argv[2];
    }

    UdpServer svr(port, ip);
    svr.init();
    svr.start();

    return 0;
}

客户端

udpClient.cc

关键步骤:创建套接字,填写服务器信息。

#include <iostream>
#include <string>
#include <cstdlib>
#include <cassert>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

static void Usage(std::string name)
{
    std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl;
}

// 客户端连接server必须先知道server的ip和port
// ./udpClient server_ip server_port
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    // 根据命令行,设置要访问的服务器ip
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);
    // 创建客户端套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);
    // client 不需要用户来 bind,而是OS自动bind

    // 填写服务器信息
    struct sockaddr_in server;
    bzero(&server, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    // 通信
    std::string buffer;
    while (true)
    {
        std::cout << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
                (const struct sockaddr*)&server, sizeof(server)); // 在首次调用sendto函数的时候,client会自动bind
    }
    return 0;
}

客户端不要自己 bind 端口,因为如果你自己绑定固定的端口,又有其他进程已经随机绑定了这个端口,那么你的客户端程序就无法启动。所以我们建议,这个临时端口的分配交给 OS 来处理。

服务端为什么要我们手动绑定端口呢?因为客户端连接服务端需要指定服务端的端口,我们希望这个端口是不变的,如果经常发生变化,就像网站的网址天天发生变化一样,会为用户带来许多麻烦。

测试

服务端绑定端口 8080

客户端向 127.0.0.1(本机) 8080 端口发送消息

img

将客户端程序和你自己的 IP 地址发给别人,让别人运行客户端就可以向你发送消息了。

当然,我们也可以改写一个 Windows 平台下的客户端,核心代码基本一样,这样就能用 Windows 给 Linux 发消息了:

#pragma warning(disable:4996)
#pragma comment(lib, "Ws2_32.lib")

#include <iostream>
#include <cassert>
#include <string>
#include <cstdio>
#include <WinSock2.h>

int server_port = 8080;
std::string server_ip = ""; // 这里填服务器ip

int main()
{
	WSADATA data;	// 用作初始化套接字
	(void)WSAStartup(MAKEWORD(2, 2), &data); // 初始化套接字

    // 创建客户端套接字
    SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    assert(sockfd > 0);
    // client 不需要用户来 bind,而是OS自动bind

    // 填写服务器信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    // 通信
    std::string buffer;
    while (true)
    {
        std::cout << "Please Enter# ";
        std::getline(std::cin, buffer);
        // 发送消息给server
        sendto(sockfd, buffer.c_str(), buffer.size(), 0,
            (const struct sockaddr*)&server, sizeof(server)); // 在首次调用sendto函数的时候,client会自动bind
    }

    closesocket(sockfd);
    WSACleanup();
	return 0;
}

TCP 通信实现

服务端

listen 函数

客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为 socket 函数创建的描述符对应于 主动套接字(active socket),它存在于一个连接的客户端。服务器调用 listen 函数告诉内核,描述符是被服务器而不是客户端使用的。

#include <sys/socket.h>

int listen(int sockfd, int backlog);
// 返回:若成功则为0,若出错则为-1

listen 函数将 sockfd 从一个主动套接字转化为一个 监听套接字(listening socket),该套接字可以接受来自客户端的连接请求。backlog 参数暗示了内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量。backlog 参数的确切含义要求对 TCP/IP 协议的理解,这超出了我们的讨论范围。通常我们也会把它设置为一个较大的值,比如 1024。

为什么要监听呢?

因为 TCP 是面向连接的。

accept 函数

服务器通过调用 accept 函数来等待来自客户端的连接请求。

#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);
// 返回:若成功则为非负连接描述符,若出错则为-1

accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个 已连接描述符(connected descriptor),这个描述符可被用来利用 Unix I/O 函数与客户端通信。

监听描述符已连接描述符的区别:

  • 监听描述符是作为客户端连接请求的一个端点。它通常被创建一次,并存在于服务器的整个生命周期。
  • 已连接描述符是客户端和服务器之间已经建立起来的连接的一个端点。服务器每次接受连接请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中。

把服务器比作餐厅,监听描述符就像是餐厅门口的迎宾员,已连接描述符就像是餐厅内的服务员。迎宾员带顾客进餐厅接受服务员的服务(与服务员取得连接),在顾客接受服务员专属服务的时候,迎宾员依然会站在门口欢迎下一位顾客。

实现

有了这些函数,我们首先可以写出服务端,该服务端提供字母小写转大写的服务:

#include "util.hpp"

class ServerTcp
{
public:
    ServerTcp(uint16_t port, const std::string &ip = "")
        : listenSock_(-1)
        , port_(port)
        , ip_(ip)
    {}

    void init()
    {
        // TCP,使用流式套接字
        listenSock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listenSock_ < 0)
        {
            logMessage(FATAL, "socket: %s", strerror(errno));
            exit(SOCKET_ERR);
        }
        // 填充服务器信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : inet_aton(ip_.c_str(), &local.sin_addr);
        // bind
        if (bind(listenSock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "bind: %s", strerror(errno));
            exit(BIND_ERR);
        }
        // 监听socket
        if (listen(listenSock_, 1024) < 0)
        {
            logMessage(FATAL, "listen: %s", strerror(errno));
            exit(LISTEN_ERR);
        }
    }
    void loop()
    {
        while (true)
        {
            // 获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listenSock_, (struct sockaddr*)&peer, &len);
            if (serviceSock < 0)
            {
                // 获取连接失败
                logMessage(WARNING, "accept: %s[%d]", strerror(errno), serviceSock);
                continue;
            }
            // 获取客户端信息
            uint16_t peerPort = ntohs(peer.sin_port);
            std::string peerIp = inet_ntoa(peer.sin_addr);

            logMessage(DEBUG, "accept: %s | %s[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 提供服务
            transService(serviceSock, peerIp, peerPort);
        }
    }
    void transService(int sock, const std::string& clientIp, uint16_t clientPort)
    {
        assert(sock >= 0);
        assert(!clientIp.empty());
        assert(clientPort >= 1024);

        char inbuffer[BUFFER_SIZE];
        while (true)
        {
            ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1);
            if (s > 0)
            {
                // 读取成功
                inbuffer[s] = '\0';
                if (strcasecmp(inbuffer, "quit") == 0)
                {
                    logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                    break;
                }
                // 转大小写
                for (int i = 0; i < s; ++i)
                {
                    if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
                        inbuffer[i] = toupper(inbuffer[i]);
                }
                write(sock, inbuffer, strlen(inbuffer));
            }
            else if (s == 0)
            {
                // 写端关闭
                logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                break;
            }
            else
            {
                // 出错
                logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
                break;
            }
        }
        close(sock);
    }
private:
    int listenSock_;
    uint16_t port_;
    std::string ip_;
};

static void Usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " proc ip" << std::endl;
}

// ./serverTcp [local_port] [local_ip]
int main(int argc, char* argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3) ip = argv[2];

    ServerTcp svr(port, ip);
    svr.init();
    svr.loop();
    return 0;
}

客户端

connect 函数

客户端通过调用 connect 函数来建立和服务器的连接。

#include <sys/socket.h>

int connect(int clientfd, const struct sockaddr *addr,
           socklen_t addrlen);
// 返回:若成功则为0,若出错则为-1

connect 函数试图与套接字地址为 addr 的服务器建立一个因特网连接,其中 addrlensizeof(sockaddr_in)connect 函数会阻塞,一直到连接成功建立或是发生错误。如果成功,clientfd 描述符现在就准备好可以读写了,并且得到的连接是由套接字对 (x:y, addr.sin_addr:addr.sin_port) 刻画的,其中 x 表示客户端的 IP 地址,而 y 表示临时端口,它唯一确定了客户端主机上的客户端进程。

实现

代码如下:

客户端不需要 listen 也不需要 accept,只要 connect。

#include "util.hpp"

volatile bool quit = false;

static void Usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " [serverIp] [serverPort]" << std::endl;
}

// ./clientTcp [serverIp] [serverPort]
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t serverPort = atoi(argv[2]);
    std::string serverIp = argv[1];

    // 创建套接字
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        std::cout << "socket: " << strerror(errno) << std::endl;
    }

    // 填充服务端信息
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    inet_aton(serverIp.c_str(), &server.sin_addr);
    // 发起请求
    if (connect(sock, (const struct sockaddr*)&server, sizeof(server)) != 0)
    {
        std::cerr << "connect: " << strerror(errno) << std::endl;
        exit(CONN_ERR);
    }
    std::cout << "info: connect success: " << sock << std::endl;

    std::string message;
    while (!quit)
    {
        message.clear();
        std::cout << "请输入你的消息# ";
        std::getline(std::cin, message);

        if (strcasecmp(message.c_str(), "quit") == 0)
            quit = true;

        // 发送消息
        ssize_t s = write(sock, message.c_str(), message.size());
        if (s > 0)
        {
            message.resize(1024);
            // 读取服务端返回的消息
            ssize_t s = read(sock, (char*)message.c_str(), 1024);
            if (s > 0) message[s] = 0;
            std::cout << "Server Echo# " << message << std::endl;
        }
        else if (s <= 0)
        {
            break;
        }
    }

    close(sock);
    return 0;
}

其他文件:

Log.hpp

用来打印日志,和上面一样,不多说。

#pragma once

#include <cstdio>
#include <cstdarg>
#include <cassert>
#include <ctime>
#include <cstdlib>
#include <cstring>
#include <cerrno>

#define DEBUG 0
#define NOTICE 1
#define WARNING 2
#define FATAL 3

const char* log_level[] = {"DEBUG", "NOTICE", "WARNING", "FATAL"};

void logMessage(int level, const char* format, ...)
{
    assert(level >= DEBUG);
    assert(level <= FATAL);

    char* name = getenv("USER");
    char logInfo[1024];
    va_list ap;
    va_start(ap, format);

    vsnprintf(logInfo, sizeof(logInfo) - 1, format, ap);

    va_end(ap);

    FILE* out = level == FATAL ? stderr : stdout;
    fprintf(out, "%s | %u | %s | %s\n", log_level[level], (unsigned int)time(nullptr), getenv("USER") == nullptr ? "unknow" : name, logInfo);
}

util.hpp

用来包含头文件,定义宏,减少在客户端和服务端的重复代码。

#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <cctype>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"

#define SOCKET_ERR 1
#define BIND_ERR 2
#define LISTEN_ERR 3
#define USAGE_ERR 4
#define CONN_ERR 5

#define BUFFER_SIZE 1024

img

但是这样写有一个问题,我们的服务在同一时间只能给一个客户使用,因为服务端是单一进程的,当服务端的执行流进入 transService 函数时就死循环了,直到连接的客户端退出,其他客户要想连接只能阻塞等待,当正在服务的客户端退出,新的客户端才能连接。

img

改进:并发服务器

为何要有监听描述符和已连接描述符之间的区别?

  • 区分这两者使得我们可以建立并发服务器,它能同时处理许多客户端连接。
  • 例如,每次一个连接请求到达监听描述符时,我们可以派生(fork)一个新的进程,它通过已连接描述符与客户端通信。

通过下面的学习,我们能更深入的体会这两者的区别。

改进1:多进程版本

在提供服务前 fork 一个进程,让子进程提供服务。关于子进程的回收,我们可以直接设置 SIGCHLD 信号为 SIG_IGN 来完成。注意,我们不应该阻塞式 waitpid 回收子进程,否则就和单进程版本一样了,可以使用非阻塞式 waitpid 回收,但是父进程需要一个数组保存所有的子进程 pid,每次对所有子进程进行轮询,比较麻烦。

    void loop()
    {
        signal(SIGCHLD, SIG_IGN);

        while (true)
        {
            // 获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listenSock_, (struct sockaddr*)&peer, &len);
            if (serviceSock < 0)
            {
                // 获取连接失败
                logMessage(WARNING, "accept: %s[%d]", strerror(errno), serviceSock);
                continue;
            }
            // 获取客户端信息
            uint16_t peerPort = ntohs(peer.sin_port);
            std::string peerIp = inet_ntoa(peer.sin_addr);

            logMessage(DEBUG, "accept: %s | %s[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 提供服务
            pid_t id = fork();
            assert(id != -1);
            if (id == 0)
            {
                // 子进程
                close(listenSock_); //子进程继承了父进程文件描述符,不用的建议关掉
                transService(serviceSock, peerIp, peerPort);
                exit(0);
            }
            // 父进程,关闭sock
            close(serviceSock);
        }
    }

子进程回收方案2:

父进程创建子进程,子进程再创建孙子进程,让孙子进程去提供服务,在提供服务之前,先让子进程退出,这样孙子进程成为孤儿进程,被系统领养,也就不用我们去担心僵尸进程的问题了。父进程阻塞式地等子进程也没有问题,因为子进程并不提供服务。

            // 提供服务
            pid_t id = fork();
            if (id == 0)
            {
                close(listenSock_);
                if (fork() > 0) exit(0);
                transService(serviceSock, peerIp, peerPort);
                exit(0);
            }
            close(serviceSock);
            pid_t ret = waitpid(id, nullptr, 0);
            assert(ret > 0);

改进2:使用线程

进程是重量级的,有点慢,我们可以使用轻量级的。

在提供服务前创建一个线程,让新线程去提供服务,由于线程的入口函数只能提供一个参数,我们只好把所有需要的信息封装成结构体。

注意,this 指针也要我们手动传,因为入口函数必须设置为 static 函数,否则会有隐含的 this 指针抢占参数位置。而我们仍然要访问类内成员函数,所以要手动传 this 指针。

struct ThreadData
{
    uint16_t clientPort_;
    std::string clientIp_;
    int sock_;
    ServerTcp* this_;

    ThreadData(uint16_t port, std::string ip, int sock,  ServerTcp* ts)
        : clientPort_(port)
        , clientIp_(ip)
        , sock_(sock)
        , this_(ts)
    {}
};

提供服务创建线程:

同样的,我们的主线程不应该使用 pthread_join 来阻塞等待一个线程。直接分离线程即可。

            // 提供服务
            ThreadData* td = new ThreadData(peerPort, peerIp, serviceSock, this);
            pthread_t tid;
            pthread_create(&tid, nullptr, threadRoutine, (void*)td);

入口函数:

我们不需要像进程版本的那样关闭文件描述符,因为线程之间的文件描述符是共享的。

    static void* threadRoutine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td = static_cast<ThreadData*>(args);
        td->this_->transService(td->sock_, td->clientIp_, td->clientPort_);
        delete td;
    }

改进3:使用线程池

使用线程池可以减少创建与释放线程造成的开销。

线程池的代码,在上一章有详细实现 [Linux](15)线程基础,线程控制,线程的互斥与同步_世真的博客-CSDN博客

在此基础上我们将它改成了单例模式。

ThreadPool.hpp

线程池里我们暂时先创建 5 个线程。

#pragma once

#include <iostream>
#include <cassert>
#include <cstdlib>
#include <memory>
#include <queue>
#include <pthread.h>
#include <unistd.h>
#include "Lock.hpp"

using namespace std;

int gThreadNum = 5;

template<class T>
class ThreadPool
{
private:
    ThreadPool(int threadNum = gThreadNum)
        : isStart_(false)
        , threadNum_(threadNum)
    {
        assert(threadNum_ > 0);
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }

    ThreadPool(const ThreadPool<T>&) = delete;
    void operator=(const ThreadPool<T>&) = delete;

public:
    static ThreadPool<T>* getInstance()
    {
        static Mutex mutex;
        if (nullptr == instance)
        {
            LockGuard lockguard(&mutex);
            if (nullptr == instance)
            {
                instance = new ThreadPool<T>;
            }
        }
        return instance;
    }
    // 类内成员,设置为static以去掉隐含的this指针,this指针只能手动传入。
    static void* threadRoutine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadPool<T>* tp = static_cast<ThreadPool<T>*>(args);
        while (1)
        {
            tp->lockQueue();
            while (!tp->haveTack())
            {
                tp->waitForTask();
            }
            T t = tp->pop();
            tp->unlockQueue();
            t();
        }
    }

    void start()
    {
        assert(!isStart_);
        for (int i = 0; i < threadNum_; ++i)
        {
            pthread_t temp;
            pthread_create(&temp, nullptr, threadRoutine, this);
        }
        isStart_ = true;
    }

    void push(const T& in)
    {
        lockQueue();
        taskQueue_.push(in);
        choiceThreadForHandler();
        unlockQueue();
    }

    ~ThreadPool()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }

    int threadNum()
    {
        return threadNum_;
    }
private:
    void lockQueue() { pthread_mutex_lock(&mutex_); }
    void unlockQueue() { pthread_mutex_unlock(&mutex_); }
    bool haveTack() { return !taskQueue_.empty(); }
    void waitForTask() { pthread_cond_wait(&cond_, &mutex_); }
    void choiceThreadForHandler() { pthread_cond_signal(&cond_); }
    T pop() 
    { 
        T temp = taskQueue_.front(); 
        taskQueue_.pop();
        return temp;
    }
private:
    bool isStart_;
    int threadNum_;
    queue<T> taskQueue_;
    pthread_mutex_t mutex_;
    pthread_cond_t cond_;

    static ThreadPool<T>* instance;
};
template<class T>
ThreadPool<T>* ThreadPool<T>::instance = nullptr;

之前封装好的锁:

#pragma once
#include <iostream>
#include <pthread.h>

using namespace std;

class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&lock_, nullptr);
    }
    
    void lock()
    {
        pthread_mutex_lock(&lock_);
    }

    void unlock()
    {
        pthread_mutex_unlock(&lock_);
    }

    ~Mutex()
    {
        pthread_mutex_destroy(&lock_);
    }

private:
    pthread_mutex_t lock_;
};

class LockGuard
{
public:
    LockGuard(Mutex* mutex)
        : mutex_(mutex)
    {
        mutex_->lock();
    }

    ~LockGuard()
    {
        mutex_->unlock();
    }
private:
    Mutex* mutex_;
};

任务结构:

Task.hpp

#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include "Log.hpp"

class Task
{
    using callBack_t = std::function<void (int, std::string, uint16_t)>;
public:
    Task()
        : sock_(-1)
        , port_(-1)
    {}
    Task(int sock, std::string ip, uint16_t port, callBack_t func)
        : sock_(sock)
        , ip_(ip)
        , port_(port)
        , func_(func)
    {}

    void operator()()
    {
        logMessage(DEBUG, "线程[%p]开始处理 %s:%d 的请求", pthread_self(), ip_.c_str(), port_);
        func_(sock_, ip_, port_);
        logMessage(DEBUG, "线程[%p]处理 %s:%d 的请求结束", pthread_self(), ip_.c_str(), port_);
    }
private:
    int sock_;
    std::string ip_;
    uint16_t port_;
    callBack_t func_;
};

服务端

serverTcp.cc

只需要增加线程池指针 ThreadPool<Task>* tp_; 属性,然后初始化部分启动线程池,在提供服务的时候创建任务,然后把任务 push 到线程池即可。

#include "util.hpp"
#include "ThreadPool.hpp"
#include "Task.hpp"

class ServerTcp;

struct ThreadData
{
    uint16_t clientPort_;
    std::string clientIp_;
    int sock_;
    ServerTcp* this_;

    ThreadData(uint16_t port, std::string ip, int sock,  ServerTcp* ts)
        : clientPort_(port)
        , clientIp_(ip)
        , sock_(sock)
        , this_(ts)
    {}
};

class ServerTcp
{
public:
    ServerTcp(uint16_t port, const std::string &ip = "")
        : listenSock_(-1)
        , port_(port)
        , ip_(ip)
        , tp_(nullptr)
    {}

    void init()
    {
        // TCP,使用流式套接字
        listenSock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listenSock_ < 0)
        {
            logMessage(FATAL, "socket: %s", strerror(errno));
            exit(SOCKET_ERR);
        }
        // 填充服务器信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : inet_aton(ip_.c_str(), &local.sin_addr);
        // bind
        if (bind(listenSock_, (const struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "bind: %s", strerror(errno));
            exit(BIND_ERR);
        }
        // 监听socket
        if (listen(listenSock_, 1024) < 0)
        {
            logMessage(FATAL, "listen: %s", strerror(errno));
            exit(LISTEN_ERR);
        }
        // 启动线程池
        tp_ = ThreadPool<Task>::getInstance();
    }

    void loop()
    {
        tp_->start();
        logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum());
        while (true)
        {
            // 获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int serviceSock = accept(listenSock_, (struct sockaddr*)&peer, &len);
            if (serviceSock < 0)
            {
                // 获取连接失败
                logMessage(WARNING, "accept: %s[%d]", strerror(errno), serviceSock);
                continue;
            }
            // 获取客户端信息
            uint16_t peerPort = ntohs(peer.sin_port);
            std::string peerIp = inet_ntoa(peer.sin_addr);

            logMessage(DEBUG, "accept: %s | %s[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock);
            // 提供服务
            Task t(serviceSock, peerIp, peerPort, std::bind(&ServerTcp::transService, this, placeholders::_1, placeholders::_2, placeholders::_3));
            tp_->push(t);
        }
    }
    void transService(int sock, const std::string& clientIp, uint16_t clientPort)
    {
        assert(sock >= 0);
        assert(!clientIp.empty());
        assert(clientPort >= 1024);

        char inbuffer[BUFFER_SIZE];
        while (true)
        {
            ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1);
            if (s > 0)
            {
                // 读取成功
                inbuffer[s] = '\0';
                if (strcasecmp(inbuffer, "quit") == 0)
                {
                    logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                    break;
                }
                // 转大小写
                for (int i = 0; i < s; ++i)
                {
                    if (isalpha(inbuffer[i]) && islower(inbuffer[i]))
                        inbuffer[i] = toupper(inbuffer[i]);
                }
                write(sock, inbuffer, strlen(inbuffer));
            }
            else if (s == 0)
            {
                // 写端关闭
                logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort);
                break;
            }
            else
            {
                // 出错
                logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno));
                break;
            }
        }
        close(sock);
    }
private:
    int listenSock_;
    uint16_t port_;
    std::string ip_;
    ThreadPool<Task>* tp_; // 线程池
};

static void Usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " proc ip" << std::endl;
}

// ./serverTcp [local_port] [local_ip]
int main(int argc, char* argv[])
{
    if (argc != 2 && argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]);
    std::string ip;
    if (argc == 3) ip = argv[2];

    ServerTcp svr(port, ip);
    svr.init();
    svr.loop();
    return 0;
}

守护进程(精灵进程)

守护进程又称精灵进程,是运行在后台的一种特殊进程。它独立于控制终端并且周期性的执行某种任务或等待处理某些发送的事件。Linux 上大多数服务器就是用守护进程实现的。

独立于终端是为了避免进程在执行过程中的信息在任何终端上显示,并且进程也不会被任何终端产生的中断信号所终止。

守护进程通常以 d 结尾

如下是一个守护进程

img

守护进程的 ppid 为 1

基本概念

进程组:PGID 一栏表示的就是进程所属的进程组

  • 每个进程都属于一个进程组
  • 进程组是一个或多个进程的集合,同一进程组中的各进程接收来自同一终端的各种信号
  • 每个进程组都有一个组长进程,组长进程的 PGID 等于其 PID

会话:SID 一栏表示的就是进程的会话 ID

  • 会话是一个或多个进程组的集合
  • 使用 setsid 函数建立一个新会话,如果调用此函数的进程不是一个进程组组长,那么创建一个新的会话。具体会发生以下三件事:
    • 该进程变成新会话的会话首进程,此时,该进程是新会话的唯一进程。
    • 该进程成为一个新进程组的组长进程,新进程组 ID 就是该进程的进程 ID
    • 该进程没有控制终端。如果调用 setsid 之前有一个控制终端,那么这种联系也被切断。

创建方式

使用 setsid 函数创建会话并设置进程组 ID

#include <unistd.h>

pid_t setsid(void);

注意,进程组的组长不能调用 setsid。否则会出错。

为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,子进程则继续,进程组的 ID 是父进程 ID,子进程 ID 是重新分配的,不可能相等,所以不可能是进程组的组长。

在服务端代码中使用下面这个函数,即可将服务端进程设置为守护进程。

#pragma once

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

void daemonize()
{
    int fd = 0;
    // 忽略 SIGPIPE
    signal(SIGPIPE, SIG_IGN);
    // 更改进程的工作目录
    // 
    // 不让自己成为进程组组长
    if (fork() > 0) exit(1);
    // 设置自己为一个独立的会话
    setsid();
    // 重定向 0 1 2
    if (fd = open("/dev/null", O_RDWR) != -1)
    {
        dup2(fd, 0);
        dup2(fd, 1);
        dup2(fd, 2);
        // 关闭fd这个多余的描述符
        if (fd > 2) close(fd);
    }
}

相关文章:

  • UI自动化总结
  • Zabbix6.0使用教程 (二)—zabbix6.0常用术语
  • java计算机毕业设计网络游戏管理网站源程序+mysql+系统+lw文档+远程调试
  • 【机器学习】Rasa NLU以及Rasa Core概念和语法简介(超详细必看)
  • Service (一) 启动/绑定服务
  • 数据结构-八大排序
  • MySQL小知识:为何从8.0开始取消了MySQL查询缓存
  • python数据类型(1)
  • HTML+CSS网页设计期末课程大作业 【茶叶文化网站设计题材】web前端开发技术 web课程设计 网页规划与设计
  • 8年三届世界杯,8年前端开发,梅西一共踢没了我八千八
  • 第十四届蓝桥杯模拟赛(第二期)——C语言版
  • c语言:关键字(一)
  • 毕业设计 单片机多功能红外空调遥控器 - 嵌入式 物联网
  • Docker 讲解与基本操作
  • 《PyInstaller打包实战指南》第二十二节 单文件模式打包Playwright
  • Apache Zeppelin在Apache Trafodion上的可视化
  • DOM的那些事
  • eclipse(luna)创建web工程
  • IDEA常用插件整理
  • k个最大的数及变种小结
  • Mac转Windows的拯救指南
  • mysql innodb 索引使用指南
  • Octave 入门
  • Python socket服务器端、客户端传送信息
  • python 学习笔记 - Queue Pipes,进程间通讯
  • Python3爬取英雄联盟英雄皮肤大图
  • Traffic-Sign Detection and Classification in the Wild 论文笔记
  • webpack4 一点通
  • 后端_MYSQL
  • 删除表内多余的重复数据
  • 使用docker-compose进行多节点部署
  • 数据科学 第 3 章 11 字符串处理
  • 数组的操作
  • 通过npm或yarn自动生成vue组件
  • 微信开放平台全网发布【失败】的几点排查方法
  • 一天一个设计模式之JS实现——适配器模式
  • mysql 慢查询分析工具:pt-query-digest 在mac 上的安装使用 ...
  • #pragma data_seg 共享数据区(转)
  • (1)安装hadoop之虚拟机准备(配置IP与主机名)
  • (C语言)逆序输出字符串
  • (M)unity2D敌人的创建、人物属性设置,遇敌掉血
  • (Matalb回归预测)PSO-BP粒子群算法优化BP神经网络的多维回归预测
  • (react踩过的坑)antd 如何同时获取一个select 的value和 label值
  • (八)五种元启发算法(DBO、LO、SWO、COA、LSO、KOA、GRO)求解无人机路径规划MATLAB
  • (差分)胡桃爱原石
  • (仿QQ聊天消息列表加载)wp7 listbox 列表项逐一加载的一种实现方式,以及加入渐显动画...
  • (附源码)spring boot儿童教育管理系统 毕业设计 281442
  • (附源码)基于SpringBoot和Vue的厨到家服务平台的设计与实现 毕业设计 063133
  • (附源码)计算机毕业设计SSM智慧停车系统
  • (附源码)计算机毕业设计SSM智能化管理的仓库管理
  • (黑客游戏)HackTheGame1.21 过关攻略
  • (经验分享)作为一名普通本科计算机专业学生,我大学四年到底走了多少弯路
  • (五) 一起学 Unix 环境高级编程 (APUE) 之 进程环境
  • **PyTorch月学习计划 - 第一周;第6-7天: 自动梯度(Autograd)**
  • .gitignore文件---让git自动忽略指定文件