⭐️ 本篇博客开始给大家介绍网络编程中的套接字编程——基于UDP协议的套接字和基于TCP的套接字,这篇博客主要介绍基于UDP协议套接字,下一篇介绍基于TCP协议的套接字。在介绍套接字编程之前,我会先给大家介绍一些预备知识:源IP地址和目的IP地址、源端口号和目的端口号等,方便大家更好地理解网络套接字编写的整个流程。需要注意的是,我们是站在应用层进行编写套接字的,所以接下来会用到都是传输层的接口。话不多说,先看今天的主要内容~
上一篇博客介绍了UDP的套接字编程,也介绍了几个相关的接口,如:socket
和bind
两个,因为UDP是面向数据报
的,所以只需要创建套接字并绑定端口号,等待数据到来即可,是比较简单的,而TCP是面向连接
的,所以TCP创建好套接字,绑定好后,还需要进行监听,等待并获取连接,所以用的的API相比也会比UDP多几个,下面正式介绍:
作用: 将套接字设置为监听状态,然后去监听socket的到来
函数原型:#include
int listen(int s, int backlog); 参数:
- s:要设置的套接字(称为监听套接字,通过socket创建)
- backlog:连接队列的长度(不建议设置太长,后面的文章会详细介绍这个参数)
返回值: 成功返回0,失败返回-1
作用: 接受请求,获取建立好的连接
函数原型:#include
#include int accept(int s, struct sockaddr *addr, socklen_t *addrlen); 参数:
- s:监听套接字
- addr:输出型参数,获取远端连接的相关信息
- addrlen:输入输出型参数,获取addr的大小长度
返回值: 成功返回一个连接套接字,用来标识远端建立好连接的套接字,失败返回-1
作用: 发起请求,请求与服务端建立连接(一般用于客户端向服务端发起请求)
函数原型:#include
#include 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服务端,成员变量包含端口号和监听套接字两个即可,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的返回值:
代码如下:
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
(忽略),此时子进程退出就会自动清理资源不会产生僵尸进程,也不会通知父进程,这种方法比较推荐,也比较简单粗暴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" << std::endl;
return 1;
}
TcpServer* tsvr = new TcpServer(atoi(argv[1]));
tsvr->TcpServerInit();
tsvr->loop();
delete tsvr;
return 0;
}
// client
#include "tcp_server.hpp"
// ./tcp_server port
int main(int argc, char* argv[])
{
if (argc != 2){
std::cout << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
TcpServer* tsvr = new TcpServer(atoi(argv[1]));
tsvr->TcpServerInit();
tsvr->loop();
delete tsvr;
return 0;
}
[wxj@VM-0-9-centos TCP1]$ cat tcp_client.cc
#include "tcp_client.hpp"
#include
// ./tcp_client server_ip server_port
int main(int argc, char* argv[])
{
if (argc != 3){
std::cout << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
return 1;
}
TcpClient* tclt = new TcpClient(argv[1], atoi(argv[2]));
tclt->TcpClientInit();
tclt->TcpClientStart();
delete tclt;
return 0;
}
观察孙子进程的父进程: 可以发现有三个进程在跑,分别是爷爷进程和两个孙子进程,孙子进程被1号进程领养
思路: 通过创建一个线程为客户端提供服务,创建好的线程之间进行线程分离,这样主线程就不需要等待其它线程了
方法: 让启动函数执行服务的代码,其中最后一个参数可以传一个类过去,这个类包含了,客户端端口号和套接字信息,如下:
struct Info
{
int _port;
std::string _ip;
int _sock;
Info(int port, std::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){
std::cout << "accept fail, continue accept" << std::endl;
continue;
}
// 多线程版本
pthread_t tid;
int peerPort = ntohs(peer.sin_port);
std::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(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;
}
为了方便测试,这里也写了一个监控脚本,监控线程数,如下:
while :; do ps -aL | head -1 && ps -aL | grep tcp_server | grep -v grep; echo "#################################"; sleep 1; done
多线程版本效果看起来还不错,但是来一个连接就创建一个线程,断开一个连接就释放一个线程,这样频繁地创建和释放线程资源,对OS来说是一种负担,同时也带来资源的浪费,如果我们使用线程池,把每一个客户端连接封装成一个任务,让线程池去处理,这样就不需要频繁地创建和销毁消除,效率也能提升很多。
线程池在前面的博客中介绍过,代码如下:
#pragma once
#include
#include
#include
#include
#include "Task.hpp"
#define DEFAULT_MAX_PTHREAD 5
class ThreadPool
{
public:
ThreadPool(int max_pthread = DEFAULT_MAX_PTHREAD)
:_max_thread(max_pthread)
{}
static void* Runtine(void* arg)
{
pthread_detach(pthread_self());
ThreadPool* this_p = (ThreadPool*)arg;
while (1){
this_p->LockQueue();
while (this_p->IsEmpty()){
this_p->ThreadWait();
}
Task* t;
this_p->Get(t);
this_p->UnlockQueue();
// 解锁后处理任务
t->Run();
delete t;
}
}
void ThreadPoolInit()
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
pthread_t t[_max_thread];
for(int i = 0; i < _max_thread; ++i)
{
pthread_create(t + i, nullptr, Runtine, this);
}
}
void Put(Task* data)
{
LockQueue();
_q.push(data);
UnlockQueue();
WakeUpThread();
}
void Get(Task*& data)
{
data = _q.front();
_q.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
public:
void LockQueue()
{
pthread_mutex_lock(&_mutex);
}
void UnlockQueue()
{
pthread_mutex_unlock(&_mutex);
}
void ThreadWait()
{
pthread_cond_wait(&_cond, &_mutex);
}
void WakeUpThread()
{
pthread_cond_signal(&_cond);
//pthread_cond_broadcast(&_cond);
}
bool IsEmpty()
{
return _q.empty();
}
private:
std::queue<Task*> _q;
int _max_thread;
pthread_mutex_t _mutex;
pthread_cond_t _cond;
};
这里我们单独写一个头文件——Task.hpp
,其中有任务类,任务类里面有三个成员变量,也就是端口号,IP和套接字,其中有一个成员方法——Run
,里面封装了一个Service
函数,也就是前面写的,把它放在Task.hpp
这个头文件下,线程池里面的线程执行run函数即可,头文件内容如下:
#pragma once
#include
#include
static 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;
}
struct Task
{
int _port;
std::string _ip;
int _sock;
Task(int port, std::string ip, int sock)
:_port(port)
,_ip(ip)
,_sock(sock)
{}
void Run()
{
Service(_ip, _port, _sock);
}
};
服务器类的核心代码如下:
void loop()
{
struct sockaddr_in peer;// 获取远端端口号和ip信息
socklen_t len = sizeof(peer);
_tp = new ThreadPool(THREAD_NUM);
_tp->ThreadPoolInit();
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;
}
int peerPort = ntohs(peer.sin_port);
std::string peerIp = inet_ntoa(peer.sin_addr);
std::cout << "get a new link, [" << peerIp << "]:[" << peerPort << "]"<< std::endl;
Task* task = new Task(peerPort, peerIp, sock);
_tp->Put(task);
}
}
注意几点变化:
线程个数观察: 只有六个,且不变,主线程1个加上线程池的5个
可以看到的是,不论服务端有多少个连接,都只有5个线程在为这些连接提供服务,这就很好地展示处理线程池带来的价值,不会频繁创建和销毁消除,不造成资源浪费,是一种不错的选择。
这里介绍TCP的相关socket API和tcp三次握手和四次挥手对应关系,三次握手和四次挥手在后面介绍tcp协议的博客中我会详细介绍,这里了解个大概即可
下面是TCP建立连接三次握手的过程和断开连接四次挥手的过程:
图中介绍了相关接口调用与实际通信对应的动作,详细动作后面的博客介绍。
几个问题:
服务器可不可以接受大量的连接?服务端是否需要维护这些连接,如何维护?
可以接受大量的连接,且需要维护,维护的方式就是
先描述再组织
,先将每一个通过一个结构体描述起来,然后通过某种数据结构将这些结构体组织起来,显然维护连接是有成本的,花费时间和空间
今天博客内容就介绍到这里了,下一篇博客开始,我会将TCP/IP四层模型自顶向下讲解它的细节已经相关协议内容,喜欢的话,欢迎点赞。收藏和关注~