Socket编程--TCP
TCP与UDP协议使用的套接字接口比较相似, 但TCP需要使用的接口更多, 细节也会更多.
接口
socket和bind不仅udp需要用到, tcp也需要. 此外还要用到三个函数:
服务端
1. int listen(int sockfd, int backlog);
头文件#include <sys/socket.h>
功能: 将套接字设置为被动监听模式, 等待连接请求
参数:
sockfd
:指向一个已绑定并且是 SOCK_STREAM 类型的套接字描述符。backlog
:内核为此套接字排队的最大连接请求数量
返回值:成功, 返回 0; 失败, 返回 -1,并设置 errno 来指示错误类型
2.int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能: 从监听套接字中提取第一个待处理的连接请求, 并为新连接创建一个新的套接字, 用于与客户端通信.
参数:
sockfd
:用于监听的套接字描述符(通过 listen() 激活)。addr
:用于存储客户端地址信息的指针,可为 NULL,不获取客户端信息。addrlen
:指向 socklen_t 类型变量的指针,用于指定和接收 addr 的大小。
返回值: 成功, 返回新的套接字描述符, 用于与客户端通信; 失败, 返回 -1, 并设置 errno 来指示错误类型
客户端
3. int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能: 主动向指定地址的服务器发起连接请求。
参数:
sockfd
:已创建的套接字描述符(通常通过 socket() 创建)addr
:指向服务器地址的结构体指针(通常是 struct sockaddr_in 或 struct sockaddr_in6)addrlen
:addr 结构的大小(以字节为单位)
返回值:成功, 返回 0; 失败, 返回 -1并设置 errno 来指示错误类型
实现TCP通信
下面来实现一个TCP通信的服务端和客户端.
服务端
(1) 准备工作
包括自定义错误码, 设置不可拷贝的类的基类 和 InetAddr用于封装ip和port
cpp
/**
* @file commond.h
* @brief 自定义错误码
*/
#pragma once
enum UdpError
{
Usage_Err = 0,
Socket_Err,
Bind_Err,
Recv_Err,
Sendto_Err,
Listen_Err,
Connect_Err
};
/**
* @file InetAddr.hpp
* @brief 封装网络地址,包括IP和端口
*/
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string>
class InetAddr
{
public:
InetAddr(const sockaddr_in& sock)
:_sock(sock)
{
sockaddr_in* temp = (sockaddr_in*)&_sock;
_port = ntohs(temp->sin_port);
_ip = inet_ntoa(temp->sin_addr);
}
uint16_t Port() const
{
return _port;
}
std::string Ip() const
{
return _ip;
}
std::string Debug()
{
std::string temp = "[";
temp += (_ip + ":" + std::to_string(_port) + "]");
return temp;
}
private:
std::string _ip;
uint16_t _port;
sockaddr_in _sock;
};
/**
* @file NotCopable.hpp
* @brief 不可拷贝的类
*/
#pragma once
class NotCopable
{
public:
NotCopable()
{}
private:
NotCopable(const NotCopable&) = delete;
NotCopable& operator=(const NotCopable&) = delete;
};
(2) 服务端类
注意这里成员我们把tcp服务端维护的socket命名为_listenfd, 这是和udp不同的一部分.
cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <string>
#include "NotCopable.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
#include "commond.h"
const int default_backlog = 1000;
class TcpServer : NotCopable
{
public:
TcpServer(uint16_t port)
: _port(port),
_isrunning(false)
{}
void Init()
{}
void Start()
{}
~TcpServer()
{}
private:
int _listenfd;
uint16_t _port;
bool _isrunning;
};
(3) 初始化Init
由于TCP属于可靠传输, 它对于网络连接的可靠性比UDP强, 所以它的服务端初始化步骤也会多一些:
- UDP:
socket()
->bind()
- TCP:
socket()
->bind()
->listen()->accept()
socket, bind 和 listen步骤都是一样的, accept是服务器启动(Start)需要的工作
注意这里多了setsockopt的步骤, 作用是为套接字 _listenfd
启用:
- 地址复用(
SO_REUSEADDR)
: 服务程序崩溃或正常退出后, 端口可能因TIME_WAIT
状态被占用.启用后,服务器可以立即重新绑定并启动 - 端口复用(
SO_REUSEPORT):
用于多进程/多线程服务共享同一个端口, 提高并发性能.
cpp
void Init()
{
// 1. socket
_listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listenfd < 0)
{
lg.LogMessage(Fatal, "sock fail %d:%s", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Debug, "socket socket success, sockfd: %d\n", _listenfd);
//解决服务端重启后bind失败问题
int opt = 1;
setsockopt(_listenfd, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
// 2. bind
struct sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(_port);
inet_pton(AF_INET, "0.0.0.0", &serverAddr.sin_addr);
if (bind(_listenfd, (sockaddr *)&serverAddr, sizeof(serverAddr)) < 0)
{
lg.LogMessage(Fatal, "bind fail %d:%s", errno, strerror(errno));
exit(Bind_Err);
}
lg.LogMessage(Debug, "bind socket success, sockfd: %d\n", _listenfd);
// 3. listen
if (listen(_listenfd, default_backlog) < 0)
{
lg.LogMessage(Fatal, "bind fail %d:%s", errno, strerror(errno));
exit(Listen_Err);
}
lg.LogMessage(Debug, "listen socket success, sockfd: %d\n", _listenfd);
}
(4) 启动服务器
启动服务器需要使用accept函数等待客户端的连接, 没有连接请求会阻塞等待, 出现连接请求则会返回一个用于通信的fd. 然后在这个fd下进行服务, 服务完成后记得关闭socket.
举个例子, 就像是一些饭店门口的招待一样, 它(listenfd)时刻等待(listen)着客户上门, 一旦有一个客户来了(connect), 它就把客人领进来(accpet)然后交给一个新的服务员(accept的返回值)为它提供服务.
cpp
void Start()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int accfd = accept(_listenfd, (sockaddr *)&client, &len);
if (accfd < 0)
{
lg.LogMessage(Info, "accept fail %d:%s", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept socket success, accfd: %d\n", accfd);
//提供服务
//版本1 -- 单进程
InetAddr cilentIA(client);
Service(accfd, cilentIA);
close(accfd);
}
_isrunning = false;
}
这里要注意的是TCP和UDP的工作方式不同:
在 TCP 协议中, 响应连接和提供服务的过程是分开的. 首先, 服务器通过监听套接字等待客户端的连接请求; 当有连接请求时, 服务器通过 accept()
系统调用接受该连接, 并为该连接创建一个新的套接字 来进行后续的数据交换 . 这意味着每个连接都有一个独立的套接字, 而不是使用同一个套接字进行所有通信. 这种机制确保了每个连接的状态被单独维护, 避免了不同连接之间的干扰.
相比之下, UDP 使用同一个套接字进行数据的发送和接收, 没有连接建立的过程, 数据包是独立的. 因此, UDP 不需要像 TCP 那样为每个连接分配独立的套接字, 适合用于短小、高效的数据传输, 但不保证数据传输的可靠性和顺序.
(5) 提供服务(Service)
这里的服务的具体实现有多种类型可以选择, 先写最简单的版本v1.
-
与 UDP 不同, TCP 是面向连接的, 不再使用recvfrom(), 而是直接通过连接套接字进行read()操作来接收数据
-
read的返回值
- 返回值 > 0: 表示成功读取到数据,返回的数值表示实际读取的字节数
- 返回值 == 0: 表示客户端关闭了连接. 此时, 服务器端可以根据这个信号关闭与客户端的连接, 清理相关资源.
- 返回值 < 0: 表示发生错误, errno 会提供详细错误信息.
值得注意的是, 类似于管道, read返回值等于0时表示写端关闭; 这里对socket的 read 返回值为0表示客户端关闭连接.
cpp
void Service(int sockfd, InetAddr addr)
{
char buffer[1024];
while(true)
{
ssize_t read_ret = read(sockfd, buffer, sizeof(buffer) - 1);
if (read_ret > 0)
{
buffer[read_ret] = '\0';
std::cout << addr.Debug() << ":" <<buffer << std::endl;
std::string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(read_ret == 0)
{
lg.LogMessage(Info, "client quit...\n");
break;
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
break;
}
}
}
客户端
(1) 主体思路
-
这里添加了断线重连机制, 默认重连次数为5.
-
对于服务器的访问封装为了一个函数visitServer.
cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include "InetAddr.hpp"
#include "commond.h"
const int reconnect_cnt = 5;
void Usage(char *proc)
{
std::cout << "Usage: \n\t" << proc << "proc_name serverIp serverPort" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return Usage_Err;
}
char* ip = argv[1];
uint16_t port = std::stoi(argv[2]);
int cnt = 1;
while(cnt <= reconnect_cnt)
{
bool ret = visitServer(port, ip, &cnt);
if(ret == false)
{
sleep(1);
std::cout << "client reconnect, cnt = " << cnt++ << std::endl;
}
else
{
break;
}
}
if(cnt > reconnect_cnt)
{
std::cout << "server offline" << std::endl;
}
return 0;
}
(2) 访问服务器
-
socket和bind是固定, 必不可少的步骤, 注意客户端是自动bind
-
TCP在正式通信之前需要先建立连接 , 通过connect与服务端建立连接.
-
为了契合断线重连 的逻辑, 我们把重连次数(pcnt)传递进来, 在每次连接成功后就把它刷新为1, 以实现下次重连时依然是从 1 开始计数.
-
通信的过程不使用sendto, 而使用 write 直接通过套接字写数据, 客户端直接用socket创建的套接字通信即可.
cpp
bool visitServer(uint16_t port, char* ip, int* pcnt)
{
bool ret = true;
// 1.socket
int clientSock = socket(AF_INET, SOCK_STREAM, 0);
if (clientSock < 0)
{
std::cerr << "sock error" << std::endl;
return false;
}
//2. 自动bind
//3. connect
struct sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(port);
inet_pton(AF_INET, ip, &serverAddr.sin_addr);
int connect_ret = connect(clientSock, (sockaddr *)&serverAddr, sizeof(serverAddr));
if(connect_ret < 0)
{
std::cerr << "connect error" << std::endl;
ret = false;
goto END;
}
*pcnt = 1;
//4. 通信
char buffer[1024];
while (true)
{
std::string msg;
std::cout << "please Enter: ";
getline(std::cin, msg);
ssize_t write_ret = write(clientSock, msg.c_str(), msg.size());
if (write_ret > 0)
{
ssize_t read_ret = read(clientSock, buffer, sizeof(buffer) - 1);
if (read_ret > 0)
{
buffer[read_ret] = '\0';
std::cout << buffer << std::endl;
}
else if (read_ret == 0)
{
//server close
//服务端关闭认为是正常的, 可能本身协议如此
break;
}
else
{
//error
ret = false;
break;
}
}
else
{
//error
std::cerr << "server may close" << std::endl;
ret = false;
break;
}
}
END:
close(clientSock);
return ret;
}
测试结果
- 可以正常的通信了:
2. 测试断线重连:
改善Service
当前我们的服务器只能同时建立一个客户端的连接, 只有在处理完当前客户端的请求后, 才能去 accept()
下一个连接. 这意味着客户端的请求是串行处理的, 只有前一个请求处理完毕, 后续的客户端才能接入, 从而导致客户端必须排队等待服务器处理, 这会影响服务器的响应效率和吞吐量.
所以我们并不希望让客户端串行等待服务器, 而是希望服务器能够并发地处理多个客户端的请求.
所以这里把服务器改为: 多进程/进程池 和多线程/线程池 的模型
多进程
每建立一个连接就创建一个子进程, 让父进程只负责accpt接受下一个连接, 子进程只负责去提供服务. 有几个需要注意的地方:
-
因此父进程就不再需要accfd, 子进程就不再需要listenfd, 推荐的做法是把父子进程都用不到的文件描述符关闭掉, 以防止文件描述符泄漏 : 假如父进程不关闭掉accfd, 随着连接的增多文件描述符会越来越多, 而文件描述符是有限的(由系统参数
ulimit -n
限制).如果长时间不关闭, 文件描述符表可能会耗尽, 导致后续的连接无法建立. -
父进程除了建立连接外, 还需要等待子进程回收.
- 阻塞式的等待会导致在子进程服务完成之前父进程都处于阻塞状态, 后续的连接无法建立, 程序依然是串行的.
- 而非阻塞式的等待
解决方案就两种:
- 在子进程代码中创建一个孙子进程, 子进程直接退出让父进程wait成功, 此时只剩下父进程和孙子进程, 而孙子进程由于父进程退出导致其变成孤儿进程, 被系统领养, 孙子进程退出时资源会被系统回收.
cpp
void Start()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int accfd = accept(_listenfd, (sockaddr *)&client, &len);
if (accfd < 0)
{
lg.LogMessage(Info, "accept fail %d:%s", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept socket success, accfd: %d\n", accfd);
//版本2 -- 多进程(父进程等待), 可以并发处理多个客户端
pid_t id = fork();
if(id < 0)
{
close(accfd);
continue;
}
else if(id == 0)
{
//child
close(_listenfd);
if(fork() > 0) exit(0);//子进程直接退出
InetAddr cilentIA(client);
Service(accfd, cilentIA);
close(accfd);
exit(0);
}
else
{
//father
close(accfd);
if(waitpid(id, nullptr, 0) < 0)
{
std::cerr << "wait fail" << std::endl;
}
}
}
_isrunning = false;
}
- 将SIGCHILD信号忽略. Linux下, 将SIGCHILD信号忽略, **子进程退出后自动释放资源,**因此就不涉及父进程的等待了.
cpp
void Start()
{
_isrunning = true;
signal(SIGCHLD, SIG_IGN);//Linux下, 将SIGCHILD信号忽略, 子进程退出后自动释放资源
while (_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int accfd = accept(_listenfd, (sockaddr *)&client, &len);
if (accfd < 0)
{
lg.LogMessage(Info, "accept fail %d:%s", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept socket success, accfd: %d\n", accfd);
//版本2.2 -- 多进程(信号版), 可以并发处理多个客户端
pid_t id = fork();
if(id < 0)
{
close(accfd);
continue;
}
else if(id == 0)
{
//child
close(_listenfd);
//提供服务
InetAddr cilentIA(client);
Service(accfd, cilentIA);
close(accfd);
exit(0);
}
else
{
//father
close(accfd);
//父进程不等待
}
}
_isrunning = false;
}
进程池
为了避免频繁创建和销毁子进程占用系统资源, 可以使用进程池, 将使用固定数量的子进程提前创建好, 等待任务的分配即可.
有bug暂略
多线程
多线程比多进程/进程池的实现更简单, 因为多线程可以共享进程资源, 比如文件描述符表,
-
由于文件描述符表是共享的, 所以主线程和新线程就不能关闭对应文件描述符, 因为使用的都是同一份资源, 主线程关闭了新线程就无法使用.
-
为了避免主线程join等待, 将新线程设置为分离状态, 任务处理完就自动释放.
cpp
class TcpServer;
class ThreadData
{
public:
ThreadData(int sockfd, const InetAddr& addr, TcpServer* ts)
:_sockfd(sockfd)
,_addr(addr)
,_ts(ts)
{}
InetAddr getAddr()
{
return _addr;
}
int getSock()
{
return _sockfd;
}
TcpServer* getTcpServer()
{
return _ts;
}
private:
int _sockfd;
InetAddr _addr;
TcpServer* _ts;
};
static void* handlerRequest(void* args)
{
//1. 初始化
pthread_detach(pthread_self());
ThreadData* td = static_cast<ThreadData*>(args);
TcpServer* ts= td->getTcpServer();
InetAddr clientAD= td->getAddr();
int sockfd = td->getSock();
//2. 提供服务
ts->Service(sockfd, clientAD);
//3. 释放资源
close(sockfd);
return nullptr;
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int accfd = accept(_listenfd, (sockaddr *)&client, &len);
if (accfd < 0)
{
lg.LogMessage(Info, "accept fail %d:%s", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept socket success, accfd: %d\n", accfd);
//v4 多线程
InetAddr clientAD(client);
ThreadData td(accfd, clientAD, this);
pthread_t tid;
pthread_create(&tid, nullptr, handlerRequest, &td);
}
_isrunning = false;
}
线程池
线程池和进程池要解决的问题类似, 避免线程频繁创建和销毁占用系统资源.
此外在线程池中还要更改一下工作的模式, 实际我们并不能为每一个服务都创建一个死循环去处理, 这样当线程池容量满时后续的连接请求就无法处理, 因此改善为服务端为客户端提供几种可选的服务, 比如 "心跳服务, 英译汉服务, 字符转大写服务 " 供客户端去选择, 服务提供完毕后此连接就被释放了.
-
服务端可以设置一个注册机制将几个函数保存在一个unordered_map中以供回调.
-
服务端要提供一个"展示服务列表"的服务提示客户端.
cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <cstring>
#include <string>
#include <unordered_map>
#include "NotCopable.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
#include "commond.h"
#include "threadPool.hpp"
class TcpServer;
const int default_backlog = 1000;
class TcpServer : NotCopable
{
using task_t = std::function<void()>;
using callback_t = std::function<void(int, InetAddr)>;
public:
TcpServer(uint16_t port)
: _port(port),
_isrunning(false)
{}
void Init()
{
// 1. socket
_listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listenfd < 0)
{
lg.LogMessage(Fatal, "sock fail %d:%s", errno, strerror(errno));
exit(Socket_Err);
}
lg.LogMessage(Debug, "socket socket success, sockfd: %d\n", _listenfd);
int opt = 1;
setsockopt(_listenfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 解决服务端重启后bind失败问题
// 2. bind
struct sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(_port);
inet_pton(AF_INET, "0.0.0.0", &serverAddr.sin_addr);
if (bind(_listenfd, (sockaddr *)&serverAddr, sizeof(serverAddr)) < 0)
{
lg.LogMessage(Fatal, "bind fail %d:%s", errno, strerror(errno));
exit(Bind_Err);
}
lg.LogMessage(Debug, "bind socket success, sockfd: %d\n", _listenfd);
// 3. listen
if (listen(_listenfd, default_backlog) < 0)
{
lg.LogMessage(Fatal, "bind fail %d:%s", errno, strerror(errno));
exit(Listen_Err);
}
lg.LogMessage(Debug, "listen socket success, sockfd: %d\n", _listenfd);
// 4. 线程池
ThreadPool<task_t>::GetInstance()->Start();
// 5. 服务列表注册
callback_t showService_bind = std::bind(&TcpServer::showService, this, std::placeholders::_1, std::placeholders::_2);
Register("default", showService_bind);
}
void Service(int sockfd, InetAddr addr)
{
//展示服务列表
_services["default"](sockfd, addr);
//读取服务
char buffer[128];
ssize_t read_ret = read(sockfd, buffer, sizeof(buffer) - 1);
if (read_ret > 0)
{
buffer[read_ret] = '\0';
string option = buffer;
lg.LogMessage(Debug, "%s selected %s", addr.Debug().c_str(), option.c_str());
if(_services.find(option) != _services.end())
_services[option](sockfd, addr);
else
{
char msg[] = "error option";
if(write(sockfd, msg, sizeof(msg)) <= 0)
{
lg.LogMessage(Debug, "write error, sockfd: %d\n", _listenfd);
}
}
}
else if (read_ret == 0)
{
lg.LogMessage(Info, "client quit...\n");
close(sockfd);
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n", errno, strerror(errno));
}
}
void showService(int sockfd, InetAddr addr)
{
std::string msg = "Service list: ";
for(const auto& service: _services)
{
if(service.first != "default")
{
msg += "| ";
msg += service.first;
}
}
if(write(sockfd, msg.c_str(), msg.size()) <= 0)
{
lg.LogMessage(Debug, "write error");
}
}
void Register(const string& name, callback_t func)
{
_services[name] = func;
}
void Start()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int accfd = accept(_listenfd, (sockaddr *)&client, &len);
if (accfd < 0)
{
lg.LogMessage(Info, "accept fail %d:%s", errno, strerror(errno));
continue;
}
lg.LogMessage(Debug, "accept socket success, accfd: %d\n", accfd);
// v5 线程池
InetAddr clientAD(client);
task_t task = std::bind(&TcpServer::Service, this, accfd, clientAD);
ThreadPool<task_t>::GetInstance()->Push(task);
}
_isrunning = false;
}
~TcpServer()
{
}
private:
int _listenfd;
uint16_t _port;
bool _isrunning;
std::unordered_map<std::string, callback_t> _services;
};
客户端:
cpp
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include "InetAddr.hpp"
#include "commond.h"
const int reconnect_cnt = 5;
void Usage(char *proc)
{
std::cout << "Usage: \n\t" << proc << "proc_name serverIp serverPort" << std::endl;
}
bool Read(int sockfd, std::string& out)
{
bool ret = true;
char buffer[1024];
ssize_t read_ret = read(sockfd, buffer, sizeof(buffer) - 1);
if (read_ret > 0)
{
buffer[read_ret] = '\0';
// std::cout << buffer << std::endl;
out = std::string(buffer);
}
else if (read_ret == 0)
{
// server close
// 服务端关闭认为是正常的, 可能本身协议如此
}
else
{
// error
ret = false;
}
return ret;
}
bool visitServer(uint16_t port, char *ip, int *pcnt)
{
// 1.socket
int clientSock = socket(AF_INET, SOCK_STREAM, 0);
if (clientSock < 0)
{
std::cerr << "sock error" << std::endl;
close(clientSock);
return false;
}
// 2. 自动bind
// 3. connect
struct sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(port);
inet_pton(AF_INET, ip, &serverAddr.sin_addr);
int connect_ret = connect(clientSock, (sockaddr *)&serverAddr, sizeof(serverAddr));
if (connect_ret < 0)
{
std::cerr << "connect error" << std::endl;
close(clientSock);
return false;
}
*pcnt = 1;
// 4. 通信
std::string server_list;
bool ret1 = Read(clientSock, server_list);//读取服务列表
std::cout << server_list << std::endl;
//选择服务
std::string msg;
std::cout << "Please select service: ";
getline(std::cin, msg);
ssize_t write_ret1 = write(clientSock, msg.c_str(), msg.size());//发送服务
bool ret2 = true, ret3 = true;
if(write_ret1 > 0)
{
//服务发送成功
std::string warnning;
ret2 = Read(clientSock, warnning);//读取服务器的提示信息
if(warnning == "error option")
{
std::cout << warnning << std::endl;
exit(0);
}
else
{
std::cout << warnning;
//发送内容
std::string content;
getline(std::cin, content);
ssize_t write_ret2 = write(clientSock, content.c_str(), content.size());
if (write_ret2 > 0)
{
std::string answer;
ret3 = Read(clientSock, answer);
std::cout << answer << std::endl;
}
else
{
// error
std::cerr << "server may close" << std::endl;
ret3 = false;
}
}
}
else
{
std::cerr << "server may close" << std::endl;
close(clientSock);
return false;
}
close(clientSock);
return ret1 && ret2 && ret3;
}
int main(int argc, char *argv[])
{
// 参数
if (argc != 3)
{
Usage(argv[0]);
return Usage_Err;
}
char *ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 默认短线重连5次
int cnt = 1;
while (cnt <= reconnect_cnt)
{
bool ret = visitServer(port, ip, &cnt);
if (ret == false)
{
sleep(1);
std::cout << "client reconnect, cnt = " << cnt++ << std::endl;
}
else
{
break;
}
}
// 重连失败
if (cnt > reconnect_cnt)
{
std::cout << "server offline" << std::endl;
}
return 0;
}
补充问题:
1. IO函数的字节序列问题
我们知道网络字节序列规定都是大端的, 所以我们需要对通过网络传输的数据进行大小端的转换, 比如我们在初始化 socket_in 时都调用了 htons 和 inet_addr(内部自动处理) 对port和ip进行了转换.
但是我们在写Service时, write 和 read 的IO操作时并没有手动转换字节序列?
这是因为**IO类函数 (**read/write) 内部自动进行了大小端转换.
2. 面向数据报/字节流
我们知道 UDP是面向数据报 的, TCP是面向字节流的. 所以之前的UDP代码是正确的, 但是目前的TCP代码是有bug的.
- 面向数据报(UDP)的特点是: 数据与数据之间是有边界的, 比如sendto发送了一次, 就一定对应recvfrom接收了一次. 数据之间是很明确的.
- 面向字节流(TCP): read收的次数和write的次数无关, 读写的步调不一致. 比如write了多次的数据可能read1次就接收完了. 这和管道很类似, 数据的处理必须由用户解析.
关于面向字节流, 举个例子, 当我们读取一个文件时, 想要划分出其中的单词, 我们对其的操作无非有两种:
- 成块的读取整个文件然后手动根据空格分隔单词.
- 单字节的读取, 直到读到空格才明确读到了一个单词.
所以我们其实是默认 按照文件中对于**单词划分的协议(以空格分隔)**去处理字节流的.
结论: 我们之前的read操作是自定义了一个缓冲区, 默认读取上来的所有字节流是一次处理, 但我们其实并不知道客户端想要传来多少字节的数据, 可能先发一部分, 再发剩余的部分. 所以TCP中要正确的处理数据, 必须结合用户协议.
守护进程
我们的网络服务器, 不能在 bash 中以前台进程的方式运行, 真正的服务器必须在Linux后台以守护进程(精灵进程)的方式运行.
进程组, 作业与会话
- 进程组
一个进程自成一个进程组:
- 当前我启动了一个进程 sleep 100000, 通过ps查看其属性, 发现进程ID 和 进程组ID 相同, 这是因为一个进程自成一个进程组
- 现在我创建了一个新的连接pts/1, 通过管道|, 同时启动两个进程: sleep 10000 | sleep 20000, 通过ps可以看到:
- 每次我们登录linux, OS会给登录的用户提供1个shell (通常是bash)和1个terminal , 用于给用户提供命令行解析 服务. 把 shell 和 terminal 可以打包为一个会话(session).
现在启动了三个连接, 就会有三个bash. 由于每个bash是一个进程, 所以每个bash自成一个进程组, 我们还可以发现每个bash自成了一个会话.
- 所以当前linux服务器上, 任何时刻, 任何一个会话内部, 可以存在多个进程组(用户级任务).
5. 但是默认任何时刻, 只允许一个进程组在前台(前台进程组), 前台是和键盘与终端相关, 可以接受IO(主要是input)的就是前台.
a. 现在创建 一个**后台进程组(作业):**sleep 10000 | sleep 20000 | sleep 30000 &, 其中[1]是进程组的编号
注意看此时的bash仍是前台进程组:Ss**+**
b. 使用 fg 进程组编号, 可以把进程变成前台进程. 由于**任何时刻, 只允许一个进程组在前台,**所以bash自动被设为后台进程.
此时bash的状态是Ss, +没了, 表示其不是一个前台进程.
c. Ctrl+Z (SIGTSTP, 20信号)可将该进程组暂停, 此时进程组状态不是Running而是Stopped, 且进程组名后无"&":
bash又自动恢复成前台进程:
d. bg 进程组编号, 可以将进程组设置为后台进程, 发现"&"添加了进去:
- 用户级任务VS进程组: 进程组是技术层面的概念, 用户级任务是用户级的概念.
服务端守护进程化
我们在服务器运行的服 务不应该受终端窗口的连接断开而关闭, 也就是说, 服务进程不应该依赖于当前终端会话的生命周期, 而应该在后台持续运行. 这种情况下, 服务应该能够独立于终端会话 运行, 即使终端关闭或用户退出, 服务进程仍然能够继续运行. 为了实现这一点, 通常会将服务程序设计为守护进程(Daemon), 也叫精灵进程.
守护进程是一个独立的会话, 它不隶属于任何终端会话. 如何把进程设置为守护进程?
通过系统调用setsid , 调用者创建一个新会话, 且此进程组成为该会话的**会话领导进程,**同时, 该进程也成为新的进程组的组长.
pid_t setsid(void);
头文件: <sys/types.h> <unistd.h>
参数: 无
返回值:
- 成功时, 返回新的会话 ID(即会话领导进程的进程PID)
- 失败时, 返回 -1, 并设置
errno.
如果调用进程已经是某个进程组的组长 或当前会话的会话领导进程, 调用会失败.
但设置守护进程还有一些其它的步骤:
- 忽略可能导致进程退出的信号
守护进程隶属于另一个独立的会话, 它的具体运行情况我们这个会话是无从得知的. 此时它就有可能受到异常信号的干扰而退出.
例如,网络服务程序通常需要与多个客户端保持连接. 如果客户端关闭了连接, 而守护进程继续尝试向这个已经关闭的连接写数据, 操作系统就会向守护进程发送 SIGPIPE
信号, 表示它无法继续写入数据. 默认情况下, 进程接收到 SIGPIPE
信号时会退出, 但在守护进程的情况下, 退出进程并非期望的行为. 守护进程应该能够继续运行,即使它与某些进程或客户端的连接已经关闭.
- 确保进程脱离当前进程组和控制终端
我们就可以fork一个子进程, 然后父进程退出, 此时子进程是一个孤儿进程, 被1号进程领养, 它也就不是组长了. 所以我们也可以认为, 守护进程本质上就是一个孤儿进程
- 创建一个新的会话并成为会话领导进程
调用setsid
- 将当前工作目录(CWD)改为根目录以避免影响文件系统
默认情况下, 进程所在的路径是当前目录, 可以通过命令: ll /proc/进程pid/cwd 查看
- 关闭或重定向标准输入输出和标准错误流
在Linux中存在一个文件: /dev/null, 向该文件中写入和读取的内容会被全部丢弃. 因为守护进程是脱离终端的, 并没有显示器, 键盘等设备文件, 所以可以把0,1,2 重定向到/dev/null. 如果无法重定向, 关闭这三个文件也行. 但推荐重定向
cpp
#pragma once
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>
#include <sys/stat.h>
#include <fcntl.h>
const char* root_path = "/";
const char* dev_null = "/dev/null";
void Daemon(bool is_chg_cwd, bool is_close_fd)
{
//1. 忽略可能引起程序异常退出的信号
signal(SIGCHLD, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
//2. 让自己不要成为进程组组长
if(fork() > 0) exit(0);
//3. 设置让自己成为一个会话, 后面的代码实际上是子进程在执行
setsid();
//4. 每一个进程都有自己的CWD, 是否把CWD改为根目录
if(is_chg_cwd)
chdir(root_path);
//5. 已经变为守护进程, 不需要和用户输入输出进行管理了
if(is_close_fd)
{
close(0);
close(1);
close(2);
}
else
{
int fd = open(dev_null, O_RDWR);
if(fd > 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
}
查看现象:
- 可以看到守护进程的父进程是1号进程:
- ll /proc/820980/fd:
发现0, 1, 2已经被重定向到/dev/null:
- ll /proc/820980/cwd, 由于server里设置为false, 所以没有变动.
简单了解TCP
简单了解TCP三次握手
TCP是面向连接 的, 在通信之前需要先连接, TCP的服务端和客户端建立连接 采用了"三次握手" 的策略. socket中的 connect 是发起了第一次握手(SYN) ,剩下的两次握手是双方OS自动进行. connect和accept都会阻塞等待三次握手完成, 握手完成后, connect才能返回从而客户端得到一个建立好连接的socket文件描述符; accept成功返回时, 服务端得到一个新的connfd.
三次握手的目的是达成共识, 没有三次握手就没有后续的通信行为:
-
- 在吗? 我要建立连接
-
- 我在, 什么时候建立?
-
- 现在建立
TCP四次挥手
-
客户端和服务端在需要断开连接时需要关闭close文件描述符, 而每次close对应两次挥手, 两次close就对应了四次挥手.
-
因为TCP是全双工 的, 客户端能向服务端发消息, 反之依成立. 所以客户端对服务端关闭连接并不代表服务端也关闭了对客户端的链接, 所以关闭连接必须要把 "客户端->服务端" 和 "服务端->客户端" 两个朝向的连接都关闭.
-
四次挥手的目的同样也是建立共识, 建立双方都要断开连接的共识.
系统层面简单理解TCP
-
既然在操作系统中建立了连接, OS就要对其进行管理. 而**"建立连接"是抽象的说法,** 实际在系统层面 本质是双方操作系统为了维护这条连接, 创建 了对应的 "连接" 结构体字段 . 所以客观上对于连接的描述, 在OS内部实际是对结构体字段的修改, 多个客户端创建的多个连接实际上是OS创建的多个结构体, 通过链表等方式组织起来. 服务器对于连接的管理变为对链表的增删查改.
-
用户层的一个系统调用, OS都为我们做了很多事情: 一个connect触发了三次挥手; 两次close触发了四次挥手. 这些底层工作都不用用户参与.
有了客户端和服务器这个概念, 未来在 TCP通信时, 首先CS双方一定要先建立链接,而TCP通信是双方的地位是对等的, 一旦建立好TCP链接, 客户端服务器可以互发消息. 为什么?
因为客户端和服务端都使用TCP协议, 它们底部会存在一个**发送缓冲区和接收缓冲区.**用来发送和接收数据. 而在通信前我们还都会建立一个用户级缓冲区, 比如最常见的一个char类型的buffer.
所以当我们把数据通过网络发送给server 的时候, 它本质上并没有把数据发送到网络中, 而是把数据从用户空间先通过 write/send 拷贝到内核的发送缓冲区中, 拷贝完成之后, write/send 这样的系统调用接口的工作就结束了.
- write/send只是拷贝函数而已, 发送缓冲区中的数据的行为(什么时候发?发多少?出错怎么办?)都是由内核TCP协议决定. 所以TCP叫做传输控制协议.
其效果等同于本地对于文件的IO操作, 我们把数据通过系统调用写到文件的内核级缓冲区. 然后OS定期把数据给我们通过一定策略刷新到磁盘.
-
TCP通信的时候实际上是双方的OS在通信 , 是把本地的发送缓冲区 通过网络拷贝到对方的接收缓冲区. 而read/recv系统调用的功能是把接收缓冲区的数据拷贝到用户空间.
-
read/recv和write/send等系统调用的策略和本地IO一样, 如果read/recv时接受缓冲区内容为空, 则OS会把进程阻塞; 而write/send时发送缓冲区满则OS也会把进程阻塞.
-
用户把数据拷贝到发送缓冲区, OS有时间时通过TCP的策略把数据通过网络发送到对方的接收缓冲区. 对于通信双方来说, 数据有人写就有人拿, 这种通信方式很像生产者消费者模型.
- TCP是面向字节流的, 我在用户层空间以为自己发送了N字节, 但是并不代表接收端一定要一次接收完这N个字节, TCP并不关心我发送的是否是完整的报文, 而只关心我发送了多少字节. 如果想要确定报文是否完整交付, 就要自己定制协议明确报文之间的边界.
UDP是面向数据报的, 它的报文是完整的, 要么就不发, 要发就必须把N个字节全部发给你, 它的内核层没有缓冲区, 我发送的是一个数据报, 接收到的就也要是数据报. 所以UDP关心的是完整的数据报, 它对于数据之间的边界是清晰的, 内核中就已经划清了报文的边界.