Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)相关的知识,希望对你有一定的参考价值。

⭐️ 本篇博客开始给大家介绍网络编程中的套接字编程——基于UDP协议的套接字和基于TCP的套接字,这篇博客主要介绍基于UDP协议套接字,下一篇介绍基于TCP协议的套接字。在介绍套接字编程之前,我会先给大家介绍一些预备知识:源IP地址和目的IP地址、源端口号和目的端口号等,方便大家更好地理解网络套接字编写的整个流程。需要注意的是,我们是站在应用层进行编写套接字的,所以接下来会用到都是传输层的接口。话不多说,先看今天的主要内容~

目录


🌏TCP相关的socket API

上一篇博客介绍了UDP的套接字编程,也介绍了几个相关的接口,如:socketbind两个,因为UDP是面向数据报的,所以只需要创建套接字并绑定端口号,等待数据到来即可,是比较简单的,而TCP是面向连接的,所以TCP创建好套接字,绑定好后,还需要进行监听,等待并获取连接,所以用的的API相比也会比UDP多几个,下面正式介绍:

  • listen

作用: 将套接字设置为监听状态,然后去监听socket的到来
函数原型:

#include <sys/socket.h> 
int listen(int s, int backlog); 

参数:

  • s:要设置的套接字(称为监听套接字,通过socket创建)
  • backlog:连接队列的长度(不建议设置太长,后面的文章会详细介绍这个参数)

返回值: 成功返回0,失败返回-1

  • accept

作用: 接受请求,获取建立好的连接
函数原型:

#include <sys/types.h>
#include <sys/socket.h>
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

参数:

  • s:监听套接字
  • addr:输出型参数,获取远端连接的相关信息
  • addrlen:输入输出型参数,获取addr的大小长度

返回值: 成功返回一个连接套接字,用来标识远端建立好连接的套接字,失败返回-1

  • connect

作用: 发起请求,请求与服务端建立连接(一般用于客户端向服务端发起请求)
函数原型:

#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

  • sockfd:套接字,发起连接请求的套接字
  • addr:描述自身的相关信息,用来标识自身,需要自己填充,让对端知道是请求方的信息,以便进行响应
  • addrlen:描述addr的大小

返回值: 成功返回0,失败返回-1

答疑解惑: 不知道大家是否对accept会有疑惑,已经通过socket创建好了一个套接字,accept又返回了一个套接字,这两个套接字有什么区别吗?UDP只又一个套接字就可以进行通信了,而TCP还需要这么多个,这是为什么?

答案是肯定有的,socket创建的套接字是用来服务端本身进行绑定的。因为UDP是面向数据报,无连接的,所以创建好一个套接字之后直接等待数据到来即可,而TCP是面向连接,需要等待连接的到来,并获取连接,普通的一个套接字是不能够进行连接的监听,这时就需要用的listen来对创建好的套接字进行设置,将其设置为监听状态,这样这个套接字就可以不断监听连接状态,如果连接到来了,就需要通过accept获取连接,获取连接后返回一个值,也是套接字,这个套接字是用来描述每一个建立好的连接,方便维护连接和给对端进行响应,后期都是通过该套接字对客户端进行通信,也就是对客户端进行服务。
所以说,开始创建的套接字是与自身强相关的,用来描述自身,并且需要进行监听,所以我们也会称这个套接字叫做监听套接字,获取到的每一个连接都用一个套接字对其进行唯一性标识,方便维护与服务。
一个通俗的类比,监听套接字好比是一家饭馆拉客的,不断地去店外拉客进店,拉客进店后顾客需要享受服务,这时就是服务员对其进行各种服务,服务员就好比是accept返回的套接字,此时拉客的不需要关心服务员是如何服务顾客的,只需要继续去店外拉客进入店内就餐即可。

🌏基于TCP协议的套接字程序

🌲服务端

TCP服务端的编写分多个版本:多进程、多线程、线程池三个版本,有这么多个版本主要是因为TCP要去服务多个不同的连接,所以单进程目前来看是不现实的,因为主线程还需要去获取新的连接,当然后面博客还会介绍多路转接的内容,可以使用单进程来进行。但这里先不介绍单进程的版本,先介绍多进程和多线程去给请求连接提供服务,下面先介绍服务端核心内容,具体服务过程放在客户端的后面,方便测试。

🍯整体框架

封装一个类,来描述tcp服务端,成员变量包含端口号和监听套接字两个即可,ip像udp服务端一样,绑定INADDR_ANY,构造函数根据传参初始化port,析构的时候关闭监听套接字即可

#define DEFAULT_PORT 8080 // 默认端口号为8080
#define BACK_LOG 5 // listen的第二个参数

class TcpServer

public:
  TcpServer(int port = DEFAULT_PORT)
    :_port(port)
     ,_listen_sock(-1)
  
  ~TcpServer()
  
    if (_listen_sock >= 0) close(_listen_sock);
  
private:
  int _port;
  int _listen_sock;
;

🍯服务端的初始化

🐚创建套接字

创建套接字这个过程相信大家都不陌生,UDP套接字那篇博客也介绍了,和UDP不同的是,TCP是面向连接的,所以第二个参数和TCP是不同的,填的是SOCK_STREAM,其它两个参数是一样的,协议家族填AF_INET,协议类别填0,具体代码如下:

bool TcpServerInit()

	// 创建套接字
	_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (_listen_sock < 0)
	  std::cerr << "socket creat fail" << std::endl;
	  return false;
	
	std::cout << "socket creat succes, sock: " << _listen_sock << std::endl;

🐚绑定端口号

绑定端口号,需要填充struct sockaddr_in这个结构体,里面有协议家族,端口号和IP,端口号根据用户传参进行填写,IP直接绑定INADDR_ANY,具体代码如下:

bool TcpServerInit()

  // 绑定
  struct sockaddr_in local;
  memset(&local, 0, sizeof(local));

  local.sin_family = AF_INET;
  local.sin_port = htons(_port);
  local.sin_addr.s_addr = INADDR_ANY;
  
  if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
    std::cout << "bind fail" << std::endl;
    return false;
  
  std::cout << "bind success" << std::endl;

🐚将套接字设置为监听状态

这里就需要用的listen这个接口,让套接字处于监听状态,然后可以去监听连接的到来代码也很简单,具体如下:

bool TcpServerInit()

  // 将套接字设置为监听状态
  if (listen(_listen_sock, BACK_LOG) < 0)
    std::cout << "listen fail" << std::endl;
    return false;
  
  std::cout << "listen success" << std::endl;

🍯循环获取连接

监听套接字通过accept获取连接,一次获取连接失败不要直接将服务端关闭,而是重新去获取连接就好,因为获取一个连接失败而直接关闭服务端,带来的损失是很大的,所以只需要重新获取连接即可,返回的用于通信套接字记录下来,进行通信,然后可以用多种方式为各种连接连接提供服务,具体服务方式后面细说,先看获取连接的一部分代码:

void loop()

  struct sockaddr_in peer;// 获取远端端口号和ip信息
  socklen_t len = sizeof(peer);
  while (1) 
    // 获取链接 
    // sock 是进行通信的一个套接字  _listen_sock 是进行监听获取链接的一个套接字 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0) 
      std::cout << "accept fail, continue accept" << std::endl; 
      continue; 
    
    // 提供服务 service 后面介绍 
  

🌲客户端

🍯整体框架

和服务端一样,封装一个类描述,类成员有服务端ip、服务端绑定的端口号以及自身套接字,代码如下:

class TcpClient

public:
  TcpClient(std::string ip, int port)
    :_server_ip(ip)
     ,_server_port(port)
     ,_sock(-1)
  
  ~TcpClient()
  
    if (_sock >= 0) close(_sock);
  
private:
  std::string _server_ip;
  int _server_port;
  int _sock;
;

🍯客户端初始化

客户端的初始化只需要创建套接字即可,不需要绑定端口号,发起连接请求的时候,会自动给客户端分配一个端口号。创建套接字和服务端是一样的,代码如下:

bool TcpClientInit()

    // 创建套接字
    _sock = socket(AF_INET, SOCK_STREAM, 0);
    if (_sock < 0)
      std::cerr << "socket creat fail" << std::endl;
      return false;
    
    std::cout << "socket creat succes, sock: " << _sock << std::endl;

    return true;

🍯客户端启动

🐚发起连接请求

使用connect函数,想服务端发起连接请求,注意,调用这个函数之前,需要先填充好服务端的信息,有协议家族、端口号和IP,请求连接失败直接退出进程,重新启动进程即可,连接成功之后就可以像服务端发起各自的服务请求(后面介绍),代码如下:

void TcpClientStart()

  // 连接服务器
  struct sockaddr_in peer;

  peer.sin_family = AF_INET;
  peer.sin_port = htons(_server_port);
  peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
  
  if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) < 0)
    // 连接失败
    std::cerr << "connect fail" << std::endl;
    exit(-1);
  
  std::cout << "connect success" << std::endl;
  Request();// 下面介绍

🐚发起服务请求

请求很简单,只需要让用户输入字符串请求,然后将请求通过write(send也可以,下篇博客介绍)发送过去,然后创建一个缓冲区,通过read(recv也可以)读取服务端的响应,这里需要着重介绍一下read的返回值
read的返回值:

  1. 大于0:实际读取的字节数
  2. 等于0:读到了文件末尾,说明对端关闭,用在服务端就是客户端关闭,用在客户端就是服务端关闭了,客户端可以直接退出
  3. 小于0:说明读取失败

代码如下:

void Request()

  std::string msg;
  while (1)
    std::cout << "Please Enter# ";
    getline(std::cin, msg);
    write(_sock, msg.c_str(), msg.size());
    char buf[256];
    ssize_t size = read(_sock, buf, sizeof(buf)-1);
    if (size <= 0)
      std::cerr << "read error" << std::endl;
      exit(-1);
    
    buf[size] = 0;
    std::cout << buf << std::endl;
  

🌲不同版本服务端服务代码

🍯多进程版本

🐚介绍

思路: 为了给不同的连接提供服务,所以我们需要让父进程去不断获取连接,获取连接后,让父进程创建一个子进程去为这个获取到的连接提供服务,那么问题来了,**子进程去服务连接,父进程是否需要等待子进程?**按常理来说,是需要的,如果不等待的话,子进程退出,子进程的资源就没有人回收,就变成僵尸进程了,如果父进程等待子进程的话,父进程就需要阻塞在哪,无法去获取到新的连接,这也是不完全可行的,所以就有了一下两种解决方案:

  1. 通过注册SIGCHLD(子进程退出会想父进程发起该信号)信号,把它的处理信号的方式改成SIG_IGN(忽略),此时子进程退出就会自动清理资源不会产生僵尸进程,也不会通知父进程,这种方法比较推荐,也比较简单粗暴
  2. 通过创建子进程,子进程创建孙子进程,子进程直接退出,让1号进程领养孙子进程,这样父进程只需要等很短的时间就可以回收子进程的资源,这样父进程可以继续去获取连接,孙子进程给连接提供服务即可

    注意: 方法二中,父进程创建好子进程之后,子进程可以将监听套接字关闭,此时该套接字对子进程来说是没有用的,当然也可以不用关闭,没有多大的浪费。但父进程关闭掉服务sock是有必要的,因为此时父进程不需要维护这些套接字了,孙子进程维护即可,如果不关闭,且有很多客户端向服务端发起请求,那么父进程这边就要维护很多不必要的套接字,让父进程的文件描述符不够用,造成文件描述符泄漏,所以父进程关闭服务套接字是必须的。
    方法一的代码编写:
void loop()

  // 对SIGCHLD信号进行注册,处理方式为忽略
  signal(SIGCHLD, SIG_IGN);
  struct sockaddr_in peer;// 获取远端端口号和ip信息
  socklen_t len = sizeof(peer);
  while (1) 
    // 获取链接 
    // sock 是进行通信的一个套接字  _listen_sock 是进行监听获取链接的一个套接字 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0) 
      std::cout << "accept fail, continue accept" << std::endl; 
      continue; 
     
 
    // 创建子进程
    pid_t id = fork();
    if (id == 0)
      // 子进程
      close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
      // 孙子进程
      int peerPort = ntohs(peer.sin_port);
      std::string peerIp = inet_ntoa(peer.sin_addr);
      std::cout << "get a new link, [" << peerIp << "]:[" << peerPort  << "]"<< std::endl;
      Service(peerIp, peerPort, sock);
    
    // 父进程继续去获取连接
  

void Service(std::string ip, int port, int sock)

   while (1)
     char buf[256];
     ssize_t size = read(sock, buf, sizeof(buf)-1);
     if (size > 0)
       // 正常读取size字节的数据
       buf[size] = 0;
       std::cout << "[" << ip << "]:[" << port  << "]# "<< buf << std::endl;
       std::string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     
     else if (size == 0)
       // 对端关闭
       std::cout << "[" << ip << "]:[" << port  << "]# close" << std::endl;
       break;
     
     else
       // 出错
       std::cerr << sock << "read error" << std::endl; 
       break;
     
   

   close(sock);
   std::cout << "service done" << std::endl;
   // 子进程退出
   exit(0);

方法二代码的编写:

void loop()

  struct sockaddr_in peer;// 获取远端端口号和ip信息
  socklen_t len = sizeof(peer);
  while (1) 
    // 获取链接 
    // sock 是进行通信的一个套接字  _listen_sock 是进行监听获取链接的一个套接字 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0) 
      std::cout << "accept fail, continue accept" << std::endl; 
      continue; 
     
    // 创建子进程
    pid_t id = fork();
    if (id == 0)
      // 子进程
      // 父子进程的文件描述符内容一致
      // 子进程可以关闭监听套接字的文件描述符
      close(_listen_sock); // 可以不关闭,但是建议关闭,以防后期子进程对监听套接字fd进行了一些操作,对父进程造成影响
      if (fork() > 0)
        // 父进程
        // 直接退出,让孙子进程被OS(1号进程)领养,退出时资源被操作系统回收
        exit(0);
      
        // 孙子进程
        int peerPort = ntohs(peer.sin_port);
        std::string peerIp = inet_ntoa(peer.sin_addr);
        std::cout << "get a new link, [" << peerIp << "]:[" << peerPort  << "]"<< std::endl;
        Service(peerIp, peerPort, sock);
    

    // 关闭sock  如果不关闭,那么爷爷进程可用文件描述符会越来越少
    close(sock);
    // 爷爷进程等儿子进程
    waitpid(-1, nullptr, 0);// 以阻塞方式等待,但这里不会阻塞,因为儿子进程是立即退出的
  

void Service(std::string ip, int port, int sock)

   while (1)
     char buf[256];
     ssize_t size = read(sock, buf, sizeof(buf)-1);
     if (size > 0)
       // 正常读取size字节的数据
       buf[size] = 0;
       std::cout << "[" << ip << "]:[" << port  << "]# "<< buf << std::endl;
       std::string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     
     else if (size == 0)
       // 对端关闭
       std::cout << "[" << ip << "]:[" << port  << "]# close" << std::endl;
       break;
     
     else
       // 出错
       std::cerr << sock << "read error" << std::endl; 
       break;
     
   

   close(sock);
   std::cout << "service done" << std::endl;
   // 孙子进程退出
   exit(0);

🐚测试

这里就置测试第二种写法,下面是一段监控脚本,监控有多少进程在运行:

while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep; echo "#################################"; sleep 1; done

运行服务端和客户端的代码如下:

// server
#include "tcp_server.hpp"

// ./tcp_server port
int main(int argc, char* argv[])

  if (argc != 2)
    std::cout << "Usage: " << argv[0] << " port" << stdLinux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Linux篇第十八篇——网络套接字编程(预备知识+UDP套接字的编写)

Linux篇第十八篇——网络套接字编程(预备知识+UDP套接字的编写)

Python基础篇第十四篇:网络编程

第九篇:网络编程补充与进程

Linux从青铜到王者第十五篇:Linux网络编程套接字两万字详解