本次socket编程需要使用到 日志文件,此为具体日志编写过程。以及 线程池,线程池原理比较简单,看注释即可。
int socket(int domain, int type, int protocol);
socket接口打开一个网络通讯端口,如果成功的话,就像 open()一样返回一个文件描述符。应用程序可以像读写文件一样用 read/write 在网络上收发数据。
int listen(int sockfd, int backlog);
listen()声明sockfd正处于监听状态, 并且最多允许有 backlog 个客户端处于连接等待状态, 如果接收到更多的连接请求就忽略, backlog设置不会太大(一般是 5), 具体细节以后再说。
int accept(int sockfd, struct sockaddr* addr, socklen_t *addrlen);
三次握手完成后, 服务器调用 accept()接受连接,如果服务器调用 accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。
int connect(int sockfd, struct sockaddr* addr, socklen_t addrlen);
客户端需要调用 connect()连接服务器。
TcpSocket编程并不是很需要我们非常理解tcp协议的原理以及实现方式方法。要实现TcpSocket编程与UdpSocket相同,都需要客户端与服务器端,服务器端实现具体的协议通信类型及方法,客户端实现对任务的调用等工作。
首先构建TcpServer服务器端,在TcpServer.hpp内写一个名为 TcpServer 的类:
const static int defaultsockfd = -1;
class TcpServer
{
public:
TcpServer(uint16_t port):_port(port), _listensock(defaultsockfd)
{}
void InitServer()// 初始化服务器
{}
void Loop()// 服务器是个死循环
{}
~TcpServer()
{}
private:
uint16_t _port;// 端口号
int _listensock;// 文件描述符
};
我们知道,服务器没有被要求停下时是一直在运行的,所以在类内我们需要实现一个Loop函数,用来维持服务器的稳定。
在初始化阶段与UdpServer类似,首先创建tcp的套接字,我们使用socket()接口创建,对返回值进行接收并判断是否合法,接着就是绑定端口和ip,绑定之前需要首先填充sockaddr_in 结构体信息字段,最后进行绑定:
// 网络中错误类型枚举,类外实现
enum
{
SOCKET_ERROR = 1,
BIND_ERROR,
LISTEN_ERROR,
USAGE_ERROR,
};
void InitServer()
{
_listensock = ::socket(AF_INET, SOCK_STREAM, 0);
if(_listensock < 0)
{
LOG(FATAL, "socket error");
exit(SOCKET_ERROR);
}
LOG(DEBUG, "socket create success, sockfd is: %d", _listensock);
//填充套接字信息
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;
int n = ::bind(_listensock, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(FATAL, "bind error");
exit(BIND_ERROR);
}
LOG(DEBUG, "bind success, sockfd is: %d", _listensock);
}
在UdpServer类内绑定完成后初始化工作就完成了,但是对于TcpServer来说,因为Tcp是面向连接的,所以在通信之前,必须得首先建立连接,而服务器将来则是被连接的,启动后未来则会一直等待客户端连接,所以我们使用listen()接口进行对客户端的监听,其中listen()接口的第二个参现在我们不需要关心,将其设置为全局变量即可:
const static int gbacklog = 16;
void InitServer()
{
_listensock = ::socket(AF_INET, SOCK_STREAM, 0);
if(_listensock < 0)
{
LOG(FATAL, "socket error");
exit(SOCKET_ERROR);
}
LOG(DEBUG, "socket create success, sockfd is: %d", _listensock);
//填充套接字信息
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;// 接收任意地址
int n = ::bind(_listensock, (struct sockaddr*)&local, sizeof(local));
if(n < 0)
{
LOG(FATAL, "bind error");
exit(BIND_ERROR);
}
LOG(DEBUG, "bind success, sockfd is: %d", _listensock);
// tcp是面向连接的,所以通信之前,必须要先建立连接, 服务器是被连接的
// tcpserver启动,未来首先是要等待客户端建立连接
n = ::listen(_listensock, gbacklog);
if(n < 0)
{
LOG(FATAL, "listen error");
exit(LISTEN_ERROR);
}
LOG(DEBUG, "listen success, sockfd is: %d", _listensock);
}
注意这里我填充网络字段用了 INADDR_ANY ,表示我使用任意IP进行通信,所以将来服务器端在启动时就可以通过./tcpserver + port 的形式运行服务器端了。
在Loop循环中,我们负责接收并处理数据,与udp不同,Tcp刚刚也说了是面向连接的,而我们收数据就需要使用到accept()这个接口了,至于这个接口最重要的不过是返回值,返回的也是文件描述符,不同的是,有几个客户端连接就会返回几个文件描述符。
用下面的例子来说明:
阿熊是一个餐馆的一个服务员,不同的是阿熊的嘴皮子还是比较突出的,所以阿熊就被委以重任,在店门口拉客,每过来一个客人,阿熊就会热情的去打招呼并介绍这里的饭店,如果有人正好饿了被阿熊的真挚所打动来到这个饭店,阿熊就会对里面喊:服务员来一位,这时候张三就出来负责这批人的饮食了。
而阿熊则不会进入到餐馆里面,还是在餐馆外拉客,每次拉倒客人就会通知后厨来一个服务员,给新来的客人进行点餐服务。
这个例子实际上就揭示了accept()与listensockfd的关系:
阿熊实际上就是listensockfd,用来负责网络中与本机发起连接的客户端,而这些所谓的服务员,实际上就是accept接口的返回值,服务器每链接一个客户端就会单独返回一个文件fd来进行服务。同时为了可以手动的控制服务器的运行,我们添加一个布尔位,用来判断是否可以支持运行:
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
}
_isrunning = false;
}
private:
uint16_t _port;
int _listensock;
bool _isrunning;
监听成功获取链接,接着就可以设计服务了,跟udpServer一样,服务方式有很多种,单进程,多线程等方式进行服务实现,首先我们来实现Version 0版本,单进程服务,直接实现一个服务接口,负责对数据进行处。
当执行到Service这一步,表示当前已经链接成功,我们需要获取客户端的端口号和IP也就是说我们需要之前UdpServer实现的InetAddr类(用来记录IP和PORT),这样我们就有了客户端的信息,同时我们需要知道是哪个客户端通过哪个套接字传递的信息。综上所述Service接口的参数必须要有sockfd,与InetAddr类对象作为形参。
void Service(int sockfd, InetAddr client) {}// Service服务
首先我们可以通过客户端的sockfd来对消息进行接收,首先设置一个缓冲区,使用read()接口对客户端发来的信息放在缓冲区,当然read()的返回值表示读取的字节数,如果我们的缓冲区大小比较小是不能一次性读完的,所以我们要分批次读取数据,如果数据读取完毕,返回值为0,如果读取失败返回值为负数,所以需要将其放在循环内:
void Service(int sockfd, InetAddr client)
{
LOG(DEBUG, "get a new link, info %s:%d, fd : %d", client.IP().c_str(), client.Port(), sockfd);
std::string clientaddr = "[" + client.IP() + ":" + std::to_string(client.Port()) + "]# ";
while(true)
{
char inbuffer[1024];
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if(n > 0)
{
// n > 0 进行处理动作
}
else if(n == 0)
{
//client 退出 && 关闭链接
LOG(INFO, "%s quit", clientaddr.c_str());
break;
}
else
{
LOG(ERROR, "read error", clientaddr.c_str());
break;
}
}
::close(sockfd);// 不关闭会导致 文件描述符泄漏的问题(文件描述符不归还)
}
当接收的返回值>0我们对数据进行处理,我们不做过多的处理,我们将数据在前面加上提示标语,然后再将数据原封不动的发回去,这就是我们的处理动作。所以完整的Service如下:
void Service(int sockfd, InetAddr client)
{
LOG(DEBUG, "get a new link, info %s:%d, fd : %d", client.IP().c_str(), client.Port(), sockfd);
std::string clientaddr = "[" + client.IP() + ":" + std::to_string(client.Port()) + "]# ";
while(true)
{
char inbuffer[1024];
ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0;
std::cout << clientaddr << inbuffer << std::endl;
std::string echo_string = "[server echo]# ";
echo_string += inbuffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if(n == 0)
{
//client 退出 && 关闭链接
LOG(INFO, "%s quit", clientaddr.c_str());
break;
}
else
{
LOG(ERROR, "read error", clientaddr.c_str());
break;
}
}
::close(sockfd);// 不关闭会导致 文件描述符泄漏的问题(文件描述符不归还)
}
服务完成就必须要关闭文件描述符,不然就是导致文件描述符泄漏,我们在系统部分学习过,文件描述符也是有限的,如果我们每一次对客户端的sockfd不归还势必会导致资源的浪费,也就是 文件描述符泄漏问题。实际上,只要是有限的资源我们不归还都有可能会导致泄漏问题。
在运行之前我们还需要对客户端代码进行编写,Tcp客户端与Udp客户端代码编写差别很小,首先进行参数控制,让其将来以 ./tcpclient + ip + port 的形式运行,接着调用socket()函数创建socket套接字。
创建完套接字之后,我们是需要通过套接字进行网络通信的,那么就需要绑定客户端IP和PORT,但是在UdpServer中我们说过,客户端是不需要绑定ip和port的,这是因为通常一个服务器不止一个ip可以访问,如果手动将其绑定就限定死只能通过这个ip来访问这个端口的服务。所以客户端是不需要绑定ip和port的,OS会自动帮你随机绑定。
虽然不用绑定端口号和IP,但是正常的网络通信我们需要进行,所以我们依旧需要填充sockaddr_in 字段,填充服务器端的信息以及IP类型:
#include <iostream>
#include <string>
#include <sys/types.h>
#include "Log.hpp"
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include "InetAddr.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n" << std::endl;
}
// ./tcp_client serverip serverport
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);// 表示进行tcp协议的通信
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
//tcp_client 要bind,但是不需要手动bind
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
return 0;
}
套接字创建完毕,网络字段也填充完毕,这个时候我们就可以通过 connect()
接口,通过sockfd,向对端发起连接,如果客户端首次调用connect接口,OS则会自动的将sockfd与对端sockaddr_in 对象进行绑定。
所以connect()内所传参数,需要服务器端的sockaddr_in 的对象信息,在上面,我们已将从命令行获取的端口号及IP,所以直接使用其IP和PORT对sockaddr_in 结构体进行填充即可。接下来我们就可以通过connect()将本地sockfd,与服务器端sockaddr_in 对象进行默认绑定了,并且朝着服务器端发起连接。
#include <iostream>
#include <string>
#include <sys/types.h>
#include "Log.hpp"
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include "InetAddr.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n" << std::endl;
}
// ./tcp_client serverip serverport
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
//tcp_client 要bind,但是不需要手动bind
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
int n = connect(sockfd, (struct sockaddr*)&server, sizeof(server));
if(n < 0)
{
std::cerr << "connect error" << std::endl;
exit(3);
}
return 0;
}
连接成功,客户端就可以向服务器端发起请求了,今天我们不做过多的处理,仅仅作为一个聊天室存在,从命令行获取信息,并且发送到服务器端,而在Tcp服务里面,我们更喜欢使用的发送消息的接口是send
,收消息使用recv
:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
与sendto和recvfrom不同的是recv和send不需要sockaddr结构体字段,也就是不需要对端的网络ip和port,这是因为调用recv和send之前已经与对端建立了链接,所以直接使用recv和send来进行通信即可。
#include <iostream>
#include <string>
#include <sys/types.h>
#include "Log.hpp"
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include "InetAddr.hpp"
void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " serverip serverport\n" << std::endl;
}
// ./tcp_client serverip serverport
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
int sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
//tcp_client 要bind,但是不需要手动bind
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
int n = connect(sockfd, (struct sockaddr*)&server, sizeof(server));
if(n < 0)
{
std::cerr << "connect error" << std::endl;
exit(3);
}
while(true)
{
std::cout << "Please Enter# ";
std::string outstring;
std::getline(std::cin, outstring);
ssize_t s = send(sockfd, outstring.c_str(), outstring.size(), 0);// write
if(s > 0)
{
char inbuffer[1024];
ssize_t m = recv(sockfd, inbuffer, sizeof(inbuffer) - 1, 0);
if(m > 0)
{
inbuffer[m] = 0;
std::cout << inbuffer << std::endl;
}
else
{
break;
}
}
else
{
break;
}
}
return 0;
}
我们使用单进程服务,在Loop获取链接之后,我们直接调用Service服务:
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// version 0: 单进程
Service(sockfd, InetAddr(peer));
}
_isrunning = false;
}
我们实现的是网络版聊天室,聊天室里总不能只有一个客户端进行通信,所以我们至少需要两个客户端进行通信,所以我们可以引入多进程。
创建进程之后,由于子进程会继承父进程的文件描述符表,这并不是共享,我们在系统部分也学过进程,我们知道子进程会继承父进程的文件描述符表,但是是以拷贝的形式继承,所以子进程与父进程都会看到 listensockfd,而我们的目的是让子进程单独处理请求,父进程负责通过accept()来获取客户端发来的请求。
也就是说,对于父进程来说accept()所返回的sockfd对自己不重要,所以父进程关闭自己文件描述符表的 sockfd,相反,accept获取请求是通过listensockfd,对于子进程来说也不重要,子进程可以关闭自己文件描述符表中的 listensockfd:
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// Version 1: 采用多进程
pid_t id = fork();
if(id == 0)
{
// child : 关心sockfd, 不关心listensock
::close(_listensock);
exit(0);
}
// father : 关心listensock, 不关心sockfd
::close(sockfd);
}
_isrunning = false;
}
我们通过多进程的目的是父进程可以不断的获取新链接,子进程不断地给新链接提供服务,但是别忘记了,父进程是需要回收子进程的,所以父进程无论以哪种方式回收子进程,都需要等待子进程结束才会继续执行新的循环。所以这样一来程序执行的顺序依旧是串行执行,这跟单进程又有什么区别呢?
没错,如果仅是如此,跟单进程的方式没有任何区别,但是我们可以在子进程中在进行fork(),创建出孙子进程,而执行fork()的子进程直接退出,这样父进程就可以接收到子进程退出信息,可以开始新的循环了。而孙子进程由于没有了父进程,则会被OS托孤,父进程变为了OS,这样我们让孙子进程执行任务,执行完直接就会被系统给回收了:
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// Version 1: 采用多进程
pid_t id = fork();
if(id == 0)
{
// child : 关心sockfd, 不关心listensock
::close(_listensock);
if(fork() > 0) exit(0);// 当前进程退出,父进程wait成功,执行新的循环
// 孙子进程
Service(sockfd, InetAddr(peer));// 孙子进程 变成孤儿进程 由系统接管
exit(0);
}
// father : 关心listensock, 不关心sockfd
::close(sockfd);
waitpid(id, nullptr, 0);
}
_isrunning = false;
}
至于父子进程为什么获取的文件描述符都是4,很简单,我们知道给用户分配的文件描述符都是从下标3开始的,因为0,1,2已经被占据,而我们定义的listensocfd就是 文件描述符3,而通过accept获取的sockfd就是文件描述符下标4,这个文件描述符表会被拷贝到孙子进程,孙子进程执行结束直接被回收了。
回到父进程时我们关闭了 accept返回的sockfd,这样以来下次父进程再次获取连接所分配的文件描述符还是4,复制到孙子进程还是四,不断循环,这样就能让文件描述符循环利用了。但是如果一次性连接多个进程就有可能分配多个文件描述符。
线程可以真正共享进程的文件描述符表,所以并不需要像多进程那样将文件描述符关闭,并且创建回收进程的成本是要比线程大的多的。创建的线程需要调用Service()接口去处理客户端请求,所以我们需要sockfd,以及客户端sockaddr 对象,于此我们将其封装为一个结构体去处理:
class ThreadData
{
public:
ThreadData(int fd, InetAddr addr)
:sockfd(fd)
,clientaddr(addr)
{}
public:
int sockfd;
InetAddr clientaddr;
};
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// versoin 2: 采用多线程
pthread_t t;
// to do...
}
_isrunning = false;
}
每个线程都会执行自己的回调函数,我们将回调函数命名为HandlerSock,通过HandlerSock来调用Service来处理客户端请求:
// 线程回调函数 类内实现
void* HandlerSock(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
// to do
delete td;
return nullptr;
}
因为回调函数是在类内实现的,而线程回调函数严格要求参数只有一个并且为 void*类型,但是类内函数第一个参数都会隐藏this 指针,所以我们可以将函数设置为静态成员函数,并且在Struct ThreadData结构体中添加 this指针字段,以便于进行线程IO:
class TcpServer;// 声明
class ThreadData
{
public:
ThreadData(int fd, InetAddr addr, TcpServer* s)
:sockfd(fd)
,clientaddr(addr)
,self(s)
{}
public:
int sockfd;
InetAddr clientaddr;
TcpServer *self;
};
static void* HandlerSock(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
td->self->Service(td->sockfd, td->clientaddr);
delete td;
return nullptr;
}
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// versoin 2: 采用多线程
pthread_t t;
ThreadData *td = new ThreadData(sockfd, InetAddr(peer), this);
pthread_create(&t, nullptr, HandlerSock, td);
}
_isrunning = false;
}
但是这样以来,似乎依旧不能实现并发,因为我们知道,main线程需要等待其他线程回收成功之后才会向下执行,而我们main线程是在循环内部的,也就是说其他线程没有回收就不会接收新的客户端。所以会导致我们执行起来依旧是串行执行。不过请不要忘记了,线程可以detach(),也就是线程分离,如果线程进行了分离,main thread就不会等待所有线程回收在向下执行了。
// 线程回调函数
static void* HandlerSock(void* args)
{
pthread_detach(pthread_self());// 进行线程分离
ThreadData* td = static_cast<ThreadData*>(args);
td->self->Service(td->sockfd, td->clientaddr);
delete td;
return nullptr;
}
不论是多进程还是多线程,它们获取连接之后才会创建进程或者线程,创建的过程本身就在消耗时间,这个时间在客户端方面也是可以感受的到的,所以我们可以采用池化技术,预先创建一批线程,等到需要的时候直接拿来用即可,我们将线程池的代码引入进来。
我们使用function来封装线程调用函数,将参数设置为空,返回值设置为void:
using task_t = std::function<void()>;// using 表示重命名与 typedef 作用相同
但是我们的Service服务的参数有两个,这个时候使用 task_t 类型 调用 Service接口肯定是会报错的,所以我们在调用前首先进行绑定,固定的将sockfd与sockaddr 对象传入,这样就可以调用了,最后线程池在获取单例执行任务即可:
using task_t = std::function<void()>;
void Loop()
{
_isrunning = true;
// 不能直接收数据,必须先获取连链接
while(_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = ::accept(_listensock, (struct sockaddr*)&peer, &len);
if(sockfd < 0)
{
LOG(WARNNING, "accept error");
continue;
}
// Version 3: 采用线程池池化技术
task_t t = std::bind(&TcpServer::Service, this, sockfd, InetAddr(peer));
ThreadPool<task_t>::GetInstance()->Enqueue(t);
}
_isrunning = false;
}