5

Linux网络通信(TCP套接字编写,多进程多线程版本) - 一只少年a

 1 year ago
source link: https://www.cnblogs.com/yzsn12138/p/16878128.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Linux网络通信(TCP套接字编写,多进程多线程版本)

源IP地址和目的IP地址

IP地址在上一篇博客中也介绍过,它是用来标识网络中不同主机的地址。两台主机进行通信时,发送方需要知道自己往哪一台主机发送,这就需要知道接受方主机的的IP地址,也就是目的IP地址,因为两台主机是要进行通信的,所以接收方需要给发送方进行一个响应,这时接收方主机就需要知道发送方主机的IP地址,也就是源IP地址。有了这两个地址,两台主机才能够找到对端主机。

  • 源IP地址: 发送方主机的IP地址,保证响应主机“往哪放”
  • 目的IP地址: 接收方主机的IP地址,保证发送方主机“往哪发”

端口号是属于传输层协议的一个概念,它是一个16位的整数,用来标识主机上的某一个进程

注意:一个端口号只能被一个进程占用

在上面说过,公网IP地址是用来标识全网内唯一的一台主机,端口号又是用来标识一台主机上的唯一一个进程,所以IP地址+端口号 就可以标识全网内唯一一个进程

端口号和进程ID:
二者都是用来唯一标识某一个进程。它们的区别和联系是:

一台主机上可以存在大量的进程,但不是所有的进程都需要对外进行网络请求。任何的网络服务和客户端进程通信,如果要进行正常的数据通信,必须要用端口号来唯一标识自身的进程,只有需要进行网络请求的进程才需要用端口号来表示自身的唯一性,所以说端口号更多的是网络级的概念。进程pid可以用来标识所有进程的唯一性,是操作系统层面的概念。二者是不同层面表示进程唯一性的机制。

源端口号和目的端口号:
两台主机进行通信,只有对端主机的IP地址只能够帮我们找到对端的主机,但是我们还需要找到对端提供服务的进程,这个进程可以通过对端进程绑定的端口号找到,也就是目的端口号,同样地,对端主机也需要给发送方一个响应,通过源IP地址找到发送方的那一台主机,找到主机还是不够的,还需要找到对端主机是哪一个进程发起了请求,响应方需要通过发起请求的进程绑定的端口号找到该进程,也就是源端口号,然后就可以进行响应。

  • 源端口号: 发送方主机的服务进程绑定的端口号,保证接收方能够找到对应的服务
  • 目的端口号: 接收方主机的服务进程绑定的端口号,保证发送方能够找到对应的服务

socket通信的本质: 跨网络的进程间通信。从上面可以看出,网络通信就是两台主机上的进程在进行通信。

注意:一个局域网才拥有一个独立的IP,IP地址只能定位到一个局域网,无法定位到具体哪台设备,要想定位到哪台设备,就必须知道这个设备的MAC地址,IP地址解决的是数据在外网(因特网,互联网)的传输问题,而MAC解决的是数据在内网(局域网)的传输问题,但是MAC地址不需要我们组包,链路层底层协议栈就会帮你组好。

Socket套接字

Socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口,供应用层调用实现进程在网络中的通信。Socket 起源于 UNIX,在 UNIX 一切皆文件的思想下,进程间通信就被冠名为文件描述符(file descriptor),Socket 是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。

2976263-20221110191308664-1400789051.png

在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。

重点:套接字本质上也是一个文件描述符,指向的是一个“网络文件”。普通文件的文件缓冲区对应的是磁盘,数据先写入文件缓冲区,再刷新到磁盘,“网络文件”的文件缓冲区对应的是网卡,它会把文件缓冲区的数据刷新到网卡,然后发送到网络中。
创建一个套接字做的工作就是打开一个文件,接下来就是要将该文件和网络关联起来,这就是绑定的操作,完成了绑定,文件缓冲区的数据才知道往哪刷新。

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有着大端和小端的区分。同样,网络数据流同样有大端和小端的区分。

思考一下,如何定义网络数据流的地址呢?

发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节一次保存在接收缓冲区中,也就是按照内存地址从低到高的顺序保存。

网络数据流的地址应该这样规定:先发出的数据是低地址,后发出的数据是高地址。

  • 大端字节序: 高位存放在低地址,低位存放在高地址
  • 小端字节序: 低位存放在低地址,高位存放在高地址
2976263-20221110191325946-1932909069.png

如果双方主机的数据在内存存储的字节序不同,就会造成接收方收到的数据出现偏差,所以为了解决这个问题,又有了下面的规定:

  • TCP/IP协议规定,网络数据流采用大端字节序,不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据
  • 所以如果发送的主机是小端机,就需要把要发送的数据先转为大端,再进行发送,如果是大端,就可以直接进行发送。

为了方便我们进行网络程序的代码编写,有下面几个API提供给我们用来做网络字节序和主机字节序的转换,如下:

#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);

说明:

  • h代表的是host,n代表的是network,s代表的是16位的短整型,l代表的是32位长整形
  • 如果主机是小端字节序,函数会对参数进行处理,进行大小端转换
  • 如果主机是大端字节序,函数不会对这些参数处理,直接返回

注意:在编程中我们需要自行进行大小端转化的就只有三个:ip地址,传输数据和端口,这两个数据需要我们进行大端的转化,其他的在计算机组包的时候会自动给我们转化。

Socket常见的API

常用的有以下几个,后面会具体的介绍

// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);

Sockaddr结构体

2976263-20221110191341110-235761132.png
  • sockaddr_in用来进行网络通信,sockaddr_un结构体用来进行本地通信
  • sockaddr_in结构体存储了协议家族,端口号,IP等信息,网络通信时可以通过这个结构体把自己的信息发送给对方,也可以通过这个结构体获取远端的这些信息
  • 可以看出,这三个结构体的前16位时一样的,代表的是协议家族,可以根据这个参数判断需要进行哪种通信(本地和跨网络)
  • IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型, 16位端口号和32位IP地址;而IPv6地址用sockaddr_in6结构体来表示
  • IPv4、 IPv6地址类型分别定义为常数AF_INET、 AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容
  • socket API可以都用struct sockaddr *类型表示,在使用的时候需要强制转化成sockaddr;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针为参数

注意:IPv4和IPv6分别有自己对应的结构体,但是为了统一,我们不知道用户要传的是ipv4还是ipv6,所以就类似于我们不知道用户要输入char还是int类型,此时我们就会写成void *类型;同理,为了统一,这里有个通用的套接字结构体struct sockaddr,将结构体IPv4和IPv6转化成sockaddr类型就可以了,struct sockaddr会根据ipv4和ipv6结构体的前几位判断需要传输的协议类型是IPv4还是IPv6。

sockaddr_in的结构: 因为我们主要用到网络通信,所以这里主要介绍这个结构体,打开/usr/include/linux/in.h

2976263-20221110191353086-1595483371.png

sin_family代表的是地址类型,我们主要用的是AF_INETsin_port代表的是端口号,sin_addr代表的是网络地址,也就是IP地址,用了一个结构体struct in_addr进行描述

struct in_addr
{
	_be32 a_addr;
}

这里填充的就是IPv4的地址,一个32位的整数

地址转换函数

IP地址可以用点分十进制的字符串(例如127.0.0.1),这里涉及到字符串和32位整网络的大端数据之间的相互转换。下面价绍二者之间转化的库函数:

int inet_pton(int af, const char *src, void *dst);
功能:
	将点分十进制字符串转换成32位网络大端的数据
参数:
	af:
		AF_INET IPV4
		AF_INET6 TPV6
	src:点分十进制串的首地址
	dst:32位网络数据的地址
返回值:成功返回1,失败返回-1

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
功能:
	将32位大端的网络数据转化成点分十进制字符串
参数:
	af:
		AF_INET IPV4
		AF_INET6 TPV6
	src:32位大端的网络数据地址
	dst:存储点分十进制串地址
	size:存储点分进制串数组的大小
返回值:成功则返回指向数组的指针,出错返回NULL
注意:net_ntop函数的dst参数不可以是一个空指针。调用者必须为目标存储单元分配内存并指定其大小,调用成功时,这个指针就是该函数的返回值

char *inet_ntoa(struct in_addr in);
参数:
	in_addr:描述ip地址的结构体

注意: inet_ntoa这个函数内部会申请一块空间,保存转换后的IP的结果,这块空间被放在静态存储区,不需要我们手动释放。且第二次调用该函数,会把结果放到上一次的静态存储区中,所以会覆盖上一次调用该函数的结果,是线程不安全的。inet_ntop这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。

TCP通信的基本流程

服务端:

1. 调用 socket 函数创建 socket(侦听socket)
2. 调用 bind 函数 将 socket绑定到某个ip和端口的二元组上
3. 调用 listen 函数 开启侦听
4. 当有客户端请求连接上来后,调用 accept 函数接受连接,产生一个新的 socket(客户端 socket)
5. 基于新产生的 socket 调用 send 或 recv 函数开始与客户端进行数据交流
6. 通信结束后,调用 close 函数关闭侦听 socket
2976263-20221110191515903-1669313986.png

看上图:给大家讲解一下服务端的流程

1.首先服务端会调用socket函数创建一个套接字,上面说过了套接字是一个特殊的”网络文件“,存在读写缓冲区

2.调用bind函数将这个套接字绑定ip和端口号,注意此时的ip和端口号都是服务器自己的端口号和ip,因为服务器是被动的连接,生成的是监听套接字,监听的是客户端发来的要连接的服务器的ip和端口号,监听套接字会查看自己绑定的ip和端口号和客户端发来的要连接的服务器的ip和端口号是否和自己一样,才能决定是否接受连接

3.调用listen函数,使得套接字变成一个被动的监听套接字,使已绑定的套接字等待监听客户端的连接请求,并设置服务器同时可以连接的数量(已连接队列和未连接队列),当监听到客户端发来的ip和端口号与未连接队列中的套接字吻合时,就把客户端发来的套接字信息放到已连接队列当中

4.调用accept函数,如果listen已连接队列中没有请求的话,该函数会阻塞,直到连接队列发来信息,该函数的第一个参数用来标识服务端套接字,第二个参数用来保存客户端套接字,实际上accept函数指定了服务器接收客户端的连接,并将客户端的套接字信息(ip和端口)保存了下来,因为当服务器给客户端发送数据的时候需要知道客户端的ip和端口

  • 值得注意的是,accept会生成一个新的套接字链接,这个套接字已经连接了服务器和客户端,原来的监听套接字和客户端的连接就会断开,以后的通信就是新的连接套接字和客户端进行通信
  • 为什么要建立一个新的套接字呢?因为监听套接字有自己的工作,还需要监听其他来访的客户端的连接请求,如果用监听套接字和客户端进行通信,那么其他客户端想要连接该服务器的端口就不会成功,影响很大

5.基于新产生的 socket 调用 send 或 recv 函数开始与客户端进行数据交流

6.通信结束后,调用 close 函数关闭侦听 socket

客户端:

1. 调用 socket函数创建客户端 socket
2. 调用 connect 函数尝试连接服务器
3. 连接成功以后调用 send 或 recv 函数开始与服务器进行数据交流
4. 通信结束后,调用 close 函数关闭侦听socket
2976263-20221110191534784-1336955644.png

TCP相关的套接字API

TCP是面向连接的,不同于UDP,TCP需要创建好套接字并且绑定端口号,绑定好之后,还需要进行监听,等待并获取连接。

  • listen
int listen(int sockfd, int backlog); 
功能:
	将套接字设置为监听状态,监听socket的到来
参数:
	sockfd:要设置的套接字(称为监听套接字)
	backlog:连接队列的长度
返回值:成功返回0,失败返回-1
  • accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:
	接受请求,获取建立好的连接
参数:
	sockfd:监听套接字
	addr:获取客户端的ip和端口信息(ipv4套接字结构体地址)
	addrlen:ipv4套接字结构体的大小的地址
socklen_t addrlen = sizeof(struct sockaddr);
返回值:成功返回一个连接套接字,用来标识远端建立好连接的套接字,失败返回-1
  • connect
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能:
	发起请求,请求与服务器建立连接(一般用于客户端向服务端发送请求)
参数:
	sockfd:套接字,发起连接请求的套接字
    addr:ipv4套接字结构体的地址,描述自身的相关信息,用来标识自身,需要自己填充,让对端知道是请求方的信息,以便进行响应
    addrlen:描述addr的大小(ipv4套接字结构体的长度)
返回值: 成功返回0,失败返回-1

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

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

基于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;
};

服务端的初始化

创建套接字

创建套接字用到的是socket这个接口,具体介绍如下:

int socket(int domain, int type, int protocol); 
功能:
	创建套接字
参数:
	domain:协议家族,我们用的都是IPV4,这里会填AF_INET
    type:协议类型。可以选择SOCK_DGRAM(数据报,UDP)和         SOCK_STREAM(流式服务,TCP)
    protocol:协议类别,这里填写0,根据前面的参数自动推导需要那种类型
返回值: 成功返回一个文件描述符,失败返回-1

代码如下:

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

绑定端口号

绑定端口号需要用到bind这个接口:

int bind(int sockfd, struct sockaddr *my_addr, socklen_taddrlen); 
参数:
	sockfd:套接字
	my_addr:这里传一个sockaddr_in的结构体,里面记录这本地的信息:sin_family(协议家族)、sin_port(端口号)和sin_addr(地址),用来进行绑定
	addrlen:第二个参数的结构体的大小
返回值: 成功返回0,失败返回-1

这里端口号我们填充一个8080,协议家族填充的还是AF_INET,这里IP绑定一个字段叫INADDR_ANY(通配地址),值为0,表示取消对单个IP的绑定,服务器端有多个IP,如果指明绑定那个IP,那么服务端只能够从这个IP获取数据,如果绑定INADDR_ANY,那么服务端可以接受来自本主机任意IP对该端口号发送过来的数据
填充好了这个结构体,我们需要它进行强转为struct sockaddr

注意: 因为数据是要发送到网络中,所以要将主机序列的端口号转为网络序列的端口号

绑定端口号,需要填充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){
    cout << "bind fail" << endl;
    return false;
  }
  cout << "bind success" << endl;
}

将套接字设置为监听状态

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

bool TcpServerInit()
{
  // 将套接字设置为监听状态
  if (listen(_listen_sock, BACK_LOG) < 0){
    cout << "listen fail" << endl;
    return false;
  }
  cout << "listen success" << 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){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    }
    // 提供服务 service 后面介绍 
  }
}

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

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

客户端初始化

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

bool TcpClientInit()
{
    // 创建套接字
    _sock = socket(AF_INET, SOCK_STREAM, 0);
    if (_sock < 0){
      cout << "socket creat fail" << endl;
      return false;
    }
    cout << "socket creat succes, sock: " << _sock << 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){
    // 连接失败
    cerr << "connect fail" <<endl;
    exit(-1);
  }
  cout << "connect success" << endl;
  Request();// 下面介绍
}

发起服务请求

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

  1. 大于0:实际读取的字节数
  2. 等于0:读到了文件末尾,说明对端关闭,用在服务端就是客户端关闭,用在客户端就是服务端关闭了,客户端可以直接退出
  3. 小于0:说明读取失败
void Request()
{
  string msg;
  while (1){
    cout << "Please Enter# ";
    getline(cin, msg);
    write(_sock, msg.c_str(), msg.size());
    char buf[256];
    ssize_t size = read(_sock, buf, sizeof(buf)-1);
    if (size <= 0){
      cerr << "read error" << endl;
      exit(-1);
    }
    buf[size] = 0;
    cout << buf << endl;
  }
}

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

多进程版本

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

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

方法一代码编写:

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){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    } 
 
    // 创建子进程
    pid_t id = fork();
    if (id == 0){
      //子进程,通信的工作交给子进程,父进程只负责监听
      close(_listen_sock);//可以不关闭,但是建议关闭,防止后期子进程对监听套接字进行一些操作,影响父进程
      //在前面的博客中讲过,父子进程共享文件表,对文件进行读写操作会影响彼此,但是由于子进程有自己的PCB,有自己的文件表项,关闭自己进程的文件描述符不会造成影响  
      int peerPort = ntohs(peer.sin_port);
      string peerIp = inet_ntoa(peer.sin_addr);
      cout << "get a new link, [" << peerIp << "]:[" << peerPort  << "]"<< endl;
      Server(peerIp, peerPort, sock);
    }
    // 父进程继续去获取连接
  }
}
void Server(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;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 对端关闭
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出错
       cerr << sock << "read error" << endl; 
       break;
     }
   }

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

完整版代码:

#include<iostream>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<signal.h>
#include<pthread.h>
using namespace std;
#define DEFAULT_PORT 8080
#define BACK_LOG 5
class TcpServer
{
public:
   TcpServer(int port = DEFAULT_PORT):_port(port),_listen_sock(-1)
   { }
  ~TcpServer()
  {
     if(_listen_sock>=0)
    {
      close(_listen_sock);
     }
  }
 public:
  //创建套接字
  bool TcpServerInit()
{
     //创建套接字
     _listen_sock = socket(AF_INET,SOCK_STREAM,0);
    if(_listen_sock<0)
     {
       cout<<"套接字创建失败"<<endl;
       return false;
     }
    cout<<"套接字创建成功,sock:"<<_listen_sock<<endl;
    //绑定端口号
     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)
     {
         cout<<"绑定失败"<<endl;
         return false;
     }
      cout<<"绑定成功"<<endl;
      //将套接字设置成监听套接字
      if(listen(_listen_sock,BACK_LOG)<0)
      {
         cout<<"监听套接字创建失败"<<endl;
         return false;
      }
         cout<<"监听套接字创建成功"<<endl;
         return true;
 }
  //循环获取连接
  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)
           {
              cout<<"accept fail,continue accept"<<endl;
              continue;
           }
          //创建子进程
          pid_t id = fork();
          if(id == 0)
          {
              //子进程
              close(_listen_sock);//可以不关闭,但是建议关闭,防止后期子进程对监听套接字进行一些操作,影响父进程
              //在前面的博客中讲过,父子进程共享文件表,对文件进行读写操作会影响彼此,但是由于子进程有自己的PCB,有自己的文件表项,关闭自己进程的文件描述符不会造成影响
              int peerPort = ntohs(peer.sin_port);
              string peerIp = inet_ntoa(peer.sin_addr);
              cout<<"获得了一个新的连接,["<< peerIp <<"]:["<< peerPort <<"]"<<endl;
              this->Server(peerIp,peerPort,sock);
           }
         //父进程继续取获取连接
       }
   }
   void Server(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;
                cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
                string msg = "server get!-> ";
                msg += buf;
                write(sock, msg.c_str(), msg.size());
         }else if (size == 0){
                // 对端关闭
                cout << "[" << ip << "]:[" << port  << "]# close" << endl;
                break;
         }
         else{
                // 出错
                cout << sock << "read error" << endl;
                break;
        }
         }
       close(sock);
       cout<<"server done"<<endl;
       //子进程退出
       exit(0);
   }
private:
   int _port;
   int _listen_sock;
};
int main(int argc,char* argv[])
{
 
  if (argc != 2){
    cout << "Usage:" << argv[0] << "port:" << endl;   
    exit(-1);
  }
  int port = atoi(argv[1]);
  TcpServer* usr = new TcpServer(port);
  usr->TcpServerInit();
  usr->loop();

  delete usr;
  system("pause");
  return EXIT_SUCCESS;
}

运行结果如下:

子进程版本

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

 //循环获取连接
 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)
          {
              cout<<"accept fail,continue accept"<<endl;
              continue;
          }
          //创建子进程
          pid_t id = fork();
          if(id == 0)
          {
            //子进程
            //子进程和父进程文件描述符一致
            close(_listen_sock);//可以不关闭,但是建议关闭,防止后期子进程对监听套接字进>    行一些操作,影响父进程
            if(fork()>0)
            {
                //父进程
                //直接退出,让孙子进程被os(1号进程)领养,退出的时候资源被操作系统回收
                exit(0);
            }
  
           //孙子进程
           int peerPort = ntohs(peer.sin_port);
           string peerIp = inet_ntoa(peer.sin_addr);
           cout<<"获得了一个新的连接,["<< peerIp <<"]:["<< peerPort <<"]"<<endl;
        this->Server(peerIp,peerPort,sock);
        }
        //关闭sock,如果不关闭,那么爷爷进程可用的文件描述符越来越少
        //通信的工作交给孙子进程
        close(sock);
        //爷爷进程等待儿子进程
        waitpid(-1,nullptr,0);
    }
 }
void Server(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;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 对端关闭
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出错
       cerr << sock << "read error" << endl; 
       break;
     }
   }

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

小伙伴们可以动手运行一下哦~

多线程版本

思路: 通过创建一个线程为客户端提供服务,创建好的线程之间进行线程分离,这样主线程就不需要等待其它线程了
方法: 让启动函数执行服务的代码,其中最后一个参数可以传一个类过去,这个类包含了,客户端端口号和套接字信息,如下:

struct Info
{
  int _port;
  std::string _ip;
  int _sock;

  Info(int port, string ip, int sock)
    :_port(port)
     ,_ip(ip)
     ,_sock(sock)
  {}
};

注意: 这里为了不让thread_run多一个this指针这个参数,所以用static修饰该函数,就没有this指针这个参数了,为了让创建出来的线程线程就可以调用该Service函数,这里将Service函数也用static修饰

static void* thread_run(void* arg)
{
  Info info = *(Info*)arg;
  delete (Info*)arg;
  // 线程分离
  pthread_detach(pthread_self());
  Service(info._ip, info._port, info._sock);
}
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){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    } 
    // 多线程版本 
    pthread_t tid; 
    int peerPort = ntohs(peer.sin_port); 
    string peerIp = inet_ntoa(peer.sin_addr);
    Info* info = new Info(peerPort, peerIp, sock); 
    pthread_create(&tid, nullptr, thread_run, (void*)info);
  }
}
static void Service(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;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf << endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 对端关闭
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出错
       cout << sock << "read error" << endl; 
       break;
     }
   }

   close(sock);
   cout << "service done" << endl;
}

线程池版本

由于还没有介绍线程池的相关知识,下一章博客将会更新线程池的知识和线程池版本的服务器代码


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK