Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)
Posted 呆呆兽学编程
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)相关的知识,希望对你有一定的参考价值。
⭐️ 本篇博客开始给大家介绍网络编程中的套接字编程——基于UDP协议的套接字和基于TCP的套接字,这篇博客主要介绍基于UDP协议套接字,下一篇介绍基于TCP协议的套接字。在介绍套接字编程之前,我会先给大家介绍一些预备知识:源IP地址和目的IP地址、源端口号和目的端口号等,方便大家更好地理解网络套接字编写的整个流程。需要注意的是,我们是站在应用层进行编写套接字的,所以接下来会用到都是传输层的接口。话不多说,先看今天的主要内容~
目录
🌏TCP相关的socket API
上一篇博客介绍了UDP的套接字编程,也介绍了几个相关的接口,如:socket
和bind
两个,因为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的返回值:
- 大于0:实际读取的字节数
- 等于0:读到了文件末尾,说明对端关闭,用在服务端就是客户端关闭,用在客户端就是服务端关闭了,客户端可以直接退出
- 小于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;
🌲不同版本服务端服务代码
🍯多进程版本
🐚介绍
思路: 为了给不同的连接提供服务,所以我们需要让父进程去不断获取连接,获取连接后,让父进程创建一个子进程去为这个获取到的连接提供服务,那么问题来了,**子进程去服务连接,父进程是否需要等待子进程?**按常理来说,是需要的,如果不等待的话,子进程退出,子进程的资源就没有人回收,就变成僵尸进程了,如果父进程等待子进程的话,父进程就需要阻塞在哪,无法去获取到新的连接,这也是不完全可行的,所以就有了一下两种解决方案:
- 通过注册
SIGCHLD
(子进程退出会想父进程发起该信号)信号,把它的处理信号的方式改成SIG_IGN
(忽略),此时子进程退出就会自动清理资源不会产生僵尸进程,也不会通知父进程,这种方法比较推荐,也比较简单粗暴 - 通过创建子进程,子进程创建孙子进程,子进程直接退出,让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套接字的编写)