目录
一、TCP协议相关函数
TCP与UDP协议使用的套接字接口比较相似,但TCP需要使用的接口更多,细节也会更多。
1.socket、bind函数
这两个函数在该篇博文第二部分socket套接字的第一部分有介绍。socket套接字——UDP协议_聪明的骑士的博客-CSDN博客
2.listen函数
int listen(int sockfd, int backlog);
头文件:sys/socket.h
功能:设置该文件描述符为监听状态。
参数:int sockfd表示之前使用socket()返回的文件描述符sockfd。
int backlog这个参数以后再说,现在说不明白。
返回值:成功返回另一个用于通信的文件描述符,失败返回-1并设置错误码errno。
3.accept函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
头文件:sys/socket.h
功能:将网络中发来的连接申请进行连接。
参数:int sockfd表示之前使用listen()返回的文件描述符。
struct sockaddr *addr是一个输出型参数,可以把与该进程连接的进程的网络信息填入其中。
socklen_t *addrlen是struct sockaddr的大小。
返回值:成功返回0,失败返回-1并设置错误码errno。
4.connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
头文件:sys/socket.h、sys/types.h
功能:将网络中发来的连接申请进行连接。
参数:int sockfd表示之前使用listen()返回的文件描述符。
struct sockaddr *addr是一个输出型参数,可以把与该进程连接的进程的网络信息填入其中。
socklen_t *addrlen是struct sockaddr的大小。
返回值:成功返回0,失败返回-1并设置错误码errno。
二、实现TCP通信
下面让我们实现一个TCP通信的服务器和客户端。
1.服务端实现
(1)服务端类
服务端类的构建与前面基本一致,全部构建在server.hpp。但成员变量只包括一个文件描述符istensock,还有一个端口号_port,可以设为缺省值。
我们依旧自己定义枚举的错误码。
static const uint16_t given_port = 8080;
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
LISTEN_ERROR
};
class udpserver
{
public:
//构造函数
tcpserver(const uint16_t& port = given_port)
:_port(port)
,_sockfd(-1)
{}
//初始化服务端进程
void initserver()
{
}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void start()
{
}
~tcpserver()
{}
private:
uint16_t _port;//服务端进程的端口号
int _sockfd;//socket返回的文件描述符
};
(2)日志小组件
日志是专门用来记录程序的执行信息的,所以我们不妨也为服务器增加记录日志的代码,将其放在log.hpp中,可以作为TCP服务器的一个小组件使用。
首先定义5个日志等级宏,它表示程序运行过程中结果的重要程度:
- DEBUG:表示调试信息,这是程序员在调试代码时看的。
- NORMAL:表示正常信息,就是代码的运行结果是符合预期的。
- WARING:表示警告信息,存在问题但只是警告,可以暂不处理。
- ERROR:表示错误信息,代表代码运行出现了错误,需要及时处理。
- FATAL:表示致命信息,代表代码出现了致命错误,无法运行下去了。
这些等级都是我们自己划分的,不同等级对应的处理方式也由我们自己控制。比如创建套接字失败,日志等级为FATAL时,输出日志完成后,我们使用exit结束进程。而创建套接字成功,日志等级为NORMAL时,就让它继续执行。其他宏也是一样,处理方式完全按我们的想法走。
由于日志都是相对固定的各式,但不同的日志输出的内容可能完全不一样。所以我们定义一个使用可变参数的logmessage函数输出日志。
void logmessage(int level, const char* format, ...);
变参函数必须包括固定参数和可变参数,其中前两个参数就是固定参数,第三个参数...表示可变参数,它本质上是占位符,可以实现变化参数个数以及类型。
C语言使用va_list系列变参宏实现变参函数,此处va(variable-argument)指代可变参数。
这是stdarg.h头文件中关于va_list的定义,va_list相关的结构基本都是宏定义的。
typedef char * va_list;
// 把 n 圆整到 sizeof(int) 的倍数
#define _INTSIZEOF(n) ( (sizeof(n)+sizeof(int)-1) & ~(sizeof(int)-1) )
// 初始化 ap 指针,使其指向第一个可变参数。v 是变参列表的前一个参数
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
// 该宏返回当前变参值,并使 ap 指向列表中的下个变参
#define va_arg(ap, type) ( *(type *)((ap += _INTSIZEOF(type)) - _INTSIZEOF(type)) )
// /将指针 ap 置为无效,结束变参的获取
#define va_end(ap) ( ap = (va_list)0 )
va_list本质上就是一个char*类型的指针。
_INTSIZEOF(n):_INTSIZEOF是va_list进行内存地址对齐(也叫栈粒度对齐)的宏,参数在内存中空间占用均为sizeof(int)的倍数,也就是4的倍数。比如,若对于参数n,1≤sizeof(n)≤4,则_INTSIZEOF(n)=4;若对于参数n,5≤sizeof(n)≤8,则_INTSIZEOF(n)=8。
va_start(ap,v):根据va_list中的参数v在栈中的内存地址,加上_INTSIZEOF(v)占内存大小后,使ap指向 v 的下一个参数。用这个宏初始化 ap 指针,v 是最后一个固定参数,初始化的结果是ap指向第一个变参。
这里的概念都很难懂,你就记得ap是可变参数列表va_list对象,v是最后一个固定参数,最后一个固定参数可认为是可变参数列表的第一个元素,通过va_start就可以让va_list对象管理可变参数就好了。
下面两个遇到再说吧,这里不解释了。
那如何将这些可变函数进行输出呢?
int vprintf(const char *format, va_list arg);
int vfprintf(FILE *stream, const char *format, va_list arg);
int vsprintf(char *str, const char *format, va_list arg);
int vsnprintf (char * sbuf, size_t n, const char * format, va_list arg );
vprintf可以将格式化的可变参数信息输出到屏幕,vfprintf可以将格式化的可变参数信息输出到文件,vprintf可以将格式化的可变参数信息输出到字符串,vprintf可以将一定字符数的格式化的可变参数信息输出到字符串。使用它们需要包含stdarg.h头文件。
这里我们使用vsnprintf,将可变参数的内容全部放入到logcontent中。
那可变参数又是怎么使用的呢?
比如logmessage(NORMAL, "create socket success");中,"create socket success"字符串会被当作可变参数,但因为只有一个字符串,所以将其传给了最后一个固定参数format。
而在logmessage(NORMAL, "create socket success:%d", _listensock);中,_listensock这个整形变量就会以可变参数的形式传给logmessage。
此时就成功打印出了监听套接字的文件描述符,包括在前面日志的前缀信息[日志等级][时间戳][pid]。
日志不仅可以将数据打印到屏幕上,而且还可以输出到文件中。
用宏先定义两个文件,将日志等级为NORMAL和DEBUG以及WARNING放入到LOG_NORMAL定义的文件路径中,将日志等级为ERROR和FATAL的放入到LOG_ERROR定义的文件路径中。
两个文件都是以追加写入的方式打开,写入完毕后再关闭文件。
服务器运行后,日志信息不仅打印在屏幕上,而且还输出到了日志文件log.error和log.txt中。
下面是最终的实现:
(3)初始化服务端
initserver()用于初始化服务端,由于TCP属于可靠传输,它对于网络连接的可靠性比较苛刻,所以它的服务端初始化步骤也会多一些。二者的服务端流程如下:
UDP:socket函数获取文件描述符fd->bind函数绑定IP
TCP:socket获取一个文件描述符fd1->bind绑定IP->用listen函数设置fd1为监听状态
我们的函数就是按照桑面TCP描述的顺序socket,bind,listen,当函数发生问题时打印错误信息并以对应错误码退出即可。
注意TCP协议是面向字节流的,socket的选项改为SOCK_STREAM。
(4)启动服务器
启动服务器需要使用accept函数等待客户端的连接,无连接请求会阻塞等待,出现连接请求则会返回一个用于通信的文件描述符 。
然后就是处理IO任务了,注意处理完成后要归还通信的文件描述符。
(5)IO任务函数
这个函数负责将客户端发来的数据打印到自己的屏幕上,然后再发回客户端。
由于网络接口集成在了文件接口中,所以使用read和write进行收发信息。
(6)析构函数
按道理监听的文件描述符也应当在不用时释放,但我们选择不释放。
(7)main函数
main函数可以直接使用以前的main函数实现。
(8)总代码
server.hpp
#pragma once
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<errno.h>
#include<stdio.h>
#include<unistd.h>
#include <sys/wait.h>
#include<pthread.h>
#include"Task.hpp"
#include"Thread.hpp"
#include"Threadpool.hpp"
static const uint16_t given_port = 8080;
static const int given_backlog = 5;
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
LISTEN_ERROR
};
using namespace std;
class tcpserver;//前置声明
class Threaddata
{
public:
Threaddata(int sock, tcpserver* pdata)
:_sock(sock)
,_pdata(pdata)
{}
int _sock;
tcpserver* _pdata;
};
class tcpserver
{
public:
//构造函数
tcpserver(const uint16_t& port = given_port)
:_port(port)
,_listensock(-1)
{}
//初始化服务端进程
void initserver()
{
_listensock = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
if(_listensock < 0)//创建套接字失败打印错误原因
{
logmessage(FATAL, "create socket error");//socket失败属于最严重的错误
exit(SOCKET_ERROR);//退出
}
logmessage(NORMAL, "create socket success:%d", _listensock);//创建套接字成功,打印让用户观察到
struct sockaddr_in local;//储存本地网络信息
local.sin_family = AF_INET;//通信方式为网络通信
local.sin_port = htons(_port);//将网络字节序的端口号填入
local.sin_addr.s_addr = INADDR_ANY;//INADDR_ANY就是ip地址0.0.0.0的宏
if(bind(_listensock, (struct sockaddr*)&local, sizeof(local)) < 0)//绑定IP,不成功打印信息
{
logmessage(FATAL, "bind socket error");//bind失败也属于最严重的错误
exit(BIND_ERROR);//退出
}
logmessage(NORMAL, "bind socket success");//绑定IP成功,打印让用户观察到
//_listensock用于监听,不是用于通信的端口号
//listen函数可设置socket为监听模式
if(listen(_listensock, given_backlog) < 0) // 第二个参数backlog后面在填这个坑
{
logmessage(FATAL, "listen socket error");
exit(LISTEN_ERROR);
}
logmessage(NORMAL, "listen socket success");
}
//线程池版本解耦程序,需要将该函数转移到Task.hpp中
void serviceIO(int sock)
{
char buffer[1024];//设置缓冲区
while(1)
{
size_t n = read(sock, buffer, sizeof(buffer)-1);//减一是为了留出一个\0
if(n > 0)//读到数据了
{
buffer[n] = '\0';//把发来的\n覆盖掉
cout << "get data:" << buffer << endl;
std::string out();
out += "buffer";
write(sock, out.c_str(), out.size());
}
else
{
logmessage(NORMAL, "Client quit, break the link.");
//n == 0服务端收不到数据了,证明客户端退出了,连接断了,客户端也断开连接
break;
}
}
}
//启动服务端进程,普通版本
void start()
{
while(1)
{
struct sockaddr_in peer;//储存本地网络信息
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
//如果没有客户端连接服务端,则accept会阻塞等待新连接
//如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
if(sock < 0)
{
logmessage(ERROR, "accept fail");//接收新文件描述符失败
continue;//重新回到头接收
}
logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
//不断处理IO任务
serviceIO(sock);
//退出该函数时,客户端已经退出,需要将进行网络传输的文件描述符释放,否则会引起资源泄露
close(sock);
//服务端释放通信的文件描述符,但不释放监听的文件描述符
//服务端进程可以继续使用该监听文件描述符阻塞等待下一个客户端连接
}
}
//启动服务端进程,线程池版本
void start()
{
//初始化线程池
ThreadPool<Task>::GetInstance()->run();
logmessage(NORMAL, "Thread init success");
while(1)
{
struct sockaddr_in peer;//储存本地网络信息
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
//如果没有客户端连接服务端,则accept会阻塞等待新连接
//如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
if(sock < 0)
{
logmessage(ERROR, "accept fail");//接收新文件描述符失败
continue;//重新回到头接收
}
logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
ThreadPool<Task>::GetInstance()->push(Task(sock, serviceIO));
}
}
//按道理不释放监听文件描述符也是一种资源泄漏,但是服务器进程大部分需要长期运行
//只有进程出问题了进程才会退出,而当进程退出时,它占用的资源也归还了操作系统,所以不释放也无所谓
~tcpserver()
{}
private:
uint16_t _port;//服务端进程的端口号
int _listensock;//监听文件描述符
};
server.cc
#include"log.hpp"
#include"server.hpp"
#include<memory>
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<tcpserver> p(new tcpserver(port));
p->initserver();
p->start();
return 0;
}
2.客户端实现
(1)客户端类
客户端类的构建与前面基本一致,成员变量不太一样,依旧自己定义枚举的错误码。
#define NUM 1024
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
CONNECT_ERROR
};
class tcpclient
{
public:
//构造函数
tcpclient(const std::string& ip, const uint16_t& port)
:_ip(ip)
,_port(port)
,_sock(-1)
{}
void initclient()
{}
//启动服务端进程,服务端需要一直运行,所以是一个死循环程序
void run()
{}
//析构函数要释放不使用的文件描述符
~tcpclient()
{
if( _sock >= 0)
close(_sock);
}
private:
int _sock;//套接字文件描述符
std::string _ip;//服务器IP地址
uint16_t _port;//服务器的端口号
};
(2)初始化客户端
initclient()用于初始化客户端,只需要创建套接字就可以了。
(3)启动客户端
客户端需要使用connect函数向服务端发连接请求,连接成功则循环发送并接收数据。
(4)IO任务函数
这个函数负责将客户端发来的数据打印到自己的屏幕上,然后再发回客户端。
由于网络接口集成在了文件接口中,所以使用read和write进行收发信息。
(5)main函数
main函数也可以直接使用以前的main函数实现。
(6)总代码
client.hpp
#pragma once
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<istream>
#include<stdlib.h>
#include<stdio.h>
#include<memory>
#define NUM 1024
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
CONNECT_ERROR
};
class tcpclient
{
public:
//构造函数
tcpclient(const std::string& ip, const uint16_t& port)
:_ip(ip)
,_port(port)
,_sock(-1)
{}
void initclient()
{
//创建套接字,创建失败打印错误原因
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock == -1)
{
logmessage(FATAL, "create socket error");//socket失败属于最严重的错误
exit(SOCKET_ERROR);//退出
}
logmessage(NORMAL, "create socket success:%d", _sock);//创建套接字成功,打印让用户观察到
//客户端不需要显式绑定,该工作交给操作系统完成
}
//启动客户端进程
void run()
{
struct sockaddr_in local;
local.sin_family = AF_INET;//通信方式为网络通信
local.sin_port = htons(_port);//将网络字节序的端口号填入
local.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
//客户端连接服务器
if(connect(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)//客户端与服务器进行连接
{
logmessage(FATAL, "connect error");//connect失败属于最严重的错误
}
else
{
std::string msg;
while(1)
{
//向服务器发送数据
printf("Please enter#");
getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
//接收服务器发回的数据
char buffer[1024];
int n = read(_sock, buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
printf("server return$%s\n", buffer);
}
else
{
//read返回0,证明服务端关闭,客户端也要关闭
break;
}
}
}
}
//析构函数要释放不使用的文件描述符
~tcpclient()
{
if( _sock >= 0)
close(_sock);
}
private:
int _sock;//套接字文件描述符
std::string _ip;//服务器IP地址
uint16_t _port;//服务器的端口号
};
client.cc
#include"log.hpp"
#include"client.hpp"
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s server_ip server_port\n\n", proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 3)//如果没输入端口号和目的IP,argc保存的命令参数就不是三个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[2]);
string ip = argv[1];
unique_ptr<tcpclient> p(new tcpclient(ip, port));
p->initclient();
p->run();
return 0;
}
三、代码改造
1.多进程版本
我们使用创建多进程的方式处理IO任务,只需要将start函数改变即可,实现的原理可以看注释。
2.多线程版本
我们使用创建多线程的方式处理IO任务,也只需要将start函数改变即可,实现的原理可以看注释。
3.线程池版本
线程池使用的就是前面的线程池(单例模式),线程池需要改变,printf注释掉,改为data()
还有start也要改,而main函数不用改。
四、最终代码
下面的代码包括了所有类型的实现,通过注释不同的字段,就可以实现不同的版本。(现在的为线程池版本)
1.线程池等准备类
log.hpp
#pragma once
#include<iostream>
#include<string>
#include<unistd.h>
#include<time.h>
#include<stdarg.h>
//按照当前程序运行的状态,定义五个宏
//NORMAL表示正常,WARNING表示有问题但程序也可运行,ERROR表示普通错误,FATAL表示严重错误
#define DEBUG 0
#define NORMAL 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
//将运行等级转换为字符串
const char* to_string(int level)
{
switch(level)
{
case(DEBUG):
return "DEBUG";
case(NORMAL):
return "NORMAL";
case(WARNING):
return "WARNING";
case(ERROR):
return "ERROR";
case(FATAL):
return "FATAL";
default:
return nullptr;
}
}
//将固定格式的日志输出到屏幕上
//第一个参数是等级,第二个参数是需要输出的字符串
void logmessage(int level, const char* format, ...)
{
char logprefix[1024];//
snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid:%d]", to_string(level), time(nullptr), getpid());//按一定格式将错误放入字符串
char logcontent[1024];//
va_list arg;//可变参数列表
va_start(arg, format);//
vsnprintf(logcontent, sizeof(logcontent), format, arg);
std::cout << logprefix << logcontent << std::endl;
}
Threadpool.hpp
#include<iostream>
#include<vector>
#include<queue>
#include<string>
#include<mutex>
#define THREAD_NUM 10
懒汉模式///
//前面加上声明
template <class T>
class ThreadPool;
//线程数据类
template <class T>
class ThreadData
{
public:
ThreadPool<T>* _pthreadpool;//线程池的this指针
std::string _threadname;//线程的名字
//构造函数
ThreadData(ThreadPool<T>* tp, std::string name)
:_pthreadpool(tp)
,_threadname(name)
{}
};
//线程池
template <class T>
class ThreadPool
{
public:
//析构函数
~ThreadPool()
{
pthread_mutex_destroy(&_cmutex);//销毁消费互斥锁
pthread_mutex_destroy(&_pmutex);//销毁生产互斥锁
pthread_cond_destroy(&_cond);//销毁条件变量
//销毁多个线程
for(size_t i = 0; i < _num; ++i)
{
_threads[i]->join();
delete _threads[i];
}
}
//将所有线程启动
void run()
{
for(size_t i = 0; i < _num; ++i)
{
//由于线程函数需要使用线程池类内的函数和每一个线程的名字,所以将它们合起来构造一个线程数据类传递给线程操作函数
ThreadData<T>* p = new ThreadData<T>(this, _threads[i]->threadname());
_threads[i]->start(handler_task, (void*)p);//这里也可以设计一个类
std::string s(p->_threadname);
s += " start...\n";
std::cout << s;
}
}
//向线程池推送任务
void push(const T& data)
{
pthread_mutex_lock(&_pmutex);
_task_queue.push(data);
pthread_cond_signal(&_cond);
pthread_mutex_unlock(&_pmutex);
}
//消费线程取任务,加锁解锁已经在消费线程处理函数里进行了,不需要注意线程安全
T pop()
{
T data = _task_queue.front();
_task_queue.pop();
return data;
}
//静态成员函数需要访问的非静态成员接口
bool isQueueEmpty() {return _task_queue.empty();}//判断任务队列是否为空
void lockQueue() {pthread_mutex_lock(&_cmutex);}//给任务队列加锁
void unlockQueue() {pthread_mutex_unlock(&_cmutex);}//给任务队列解锁
void threadWait() {pthread_cond_wait(&_cond,&_cmutex);}//将线程放入条件变量的等待队列中
//获取单例对象指针的接口
static ThreadPool<T>* GetInstance()
{
if(_pobj == nullptr)
{
_singlelock.lock();
if(_pobj == nullptr)
{
_pobj = new ThreadPool<T>();
}
_singlelock.unlock();
}
return _pobj;
}
private:
//构造函数
ThreadPool(int num = THREAD_NUM)
:_num(num)
{
pthread_mutex_init(&_cmutex, nullptr);//初始化消费互斥锁
pthread_mutex_init(&_pmutex, nullptr);//初始化生产互斥锁
pthread_cond_init(&_cond, nullptr);//初始化条件变量
//创建多个线程
for(size_t i = 0; i < _num; ++i)
{
_threads.push_back(new Thread());
}
}
ThreadPool(const ThreadPool<T>& tp) = delete;//禁止拷贝构造
ThreadPool<T>& operator=(const ThreadPool<T>& tp) = delete;//禁止赋值运算符重载
//消费线程的处理函数
static void* handler_task(void* args)
{
ThreadData<T>* p = (ThreadData<T>*)args;
while(1)
{
p->_pthreadpool->lockQueue();
//如果任务队列为空,消费者进程会被加入到条件变量的阻塞队列中
while(p->_pthreadpool->isQueueEmpty())
{
p->_pthreadpool->threadWait();
}
T data = p->_pthreadpool->pop();
p->_pthreadpool->unlockQueue();
data();
//printf("%s接受了任务%s并处理完成,结果为:%s\n", p->_threadname.c_str(),
//data.show_task().c_str(), data().c_str());
}
delete p;
return nullptr;
}
int _num;//维护的线程数量
std::vector<Thread*> _threads;//管理多个线程对象的容器
std::queue<T> _task_queue;//任务队列
pthread_mutex_t _cmutex;//消费者互斥锁
pthread_cond_t _cond;//条件变量
pthread_mutex_t _pmutex;//生成任务时的互斥锁
static ThreadPool<T>* _pobj;//静态单例对象
static std::mutex _singlelock;
//由于单例的建立有线程安全问题,所以需要加锁,这里为了方便使用了C++11提供的互斥锁
};
template<class T>
ThreadPool<T>* ThreadPool<T>::_pobj = nullptr;//ThreadPool<T>单例先不初始化
template<class T>
std::mutex ThreadPool<T>::_singlelock;//初始化锁
2.服务端
server.hpp
#pragma once
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<errno.h>
#include<stdio.h>
#include<unistd.h>
#include <sys/wait.h>
#include<pthread.h>
#include"Task.hpp"
#include"Thread.hpp"
#include"Threadpool.hpp"
static const uint16_t given_port = 8080;
static const int given_backlog = 5;
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
LISTEN_ERROR
};
using namespace std;
class tcpserver;//前置声明
class Threaddata
{
public:
Threaddata(int sock, tcpserver* pdata)
:_sock(sock)
,_pdata(pdata)
{}
int _sock;
tcpserver* _pdata;
};
class tcpserver
{
public:
//构造函数
tcpserver(const uint16_t& port = given_port)
:_port(port)
,_listensock(-1)
{}
//初始化服务端进程
void initserver()
{
_listensock = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
if(_listensock < 0)//创建套接字失败打印错误原因
{
logmessage(FATAL, "create socket error");//socket失败属于最严重的错误
exit(SOCKET_ERROR);//退出
}
logmessage(NORMAL, "create socket success:%d", _listensock);//创建套接字成功,打印让用户观察到
struct sockaddr_in local;//储存本地网络信息
local.sin_family = AF_INET;//通信方式为网络通信
local.sin_port = htons(_port);//将网络字节序的端口号填入
local.sin_addr.s_addr = INADDR_ANY;//INADDR_ANY就是ip地址0.0.0.0的宏
if(bind(_listensock, (struct sockaddr*)&local, sizeof(local)) < 0)//绑定IP,不成功打印信息
{
logmessage(FATAL, "bind socket error");//bind失败也属于最严重的错误
exit(BIND_ERROR);//退出
}
logmessage(NORMAL, "bind socket success");//绑定IP成功,打印让用户观察到
//_listensock用于监听,不是用于通信的端口号
//listen函数可设置socket为监听模式
if(listen(_listensock, given_backlog) < 0) // 第二个参数backlog后面在填这个坑
{
logmessage(FATAL, "listen socket error");
exit(LISTEN_ERROR);
}
logmessage(NORMAL, "listen socket success");
}
// //线程池版本解耦程序,需要将该函数转移到Task.hpp中
// void serviceIO(int sock)
// {
// char buffer[1024];//设置缓冲区
// while(1)
// {
// size_t n = read(sock, buffer, sizeof(buffer)-1);//减一是为了留出一个\0
// if(n > 0)//读到数据了
// {
// buffer[n] = '\0';//把发来的\n覆盖掉
// cout << "get data:" << buffer << endl;
// std::string out();
// out += "buffer";
// write(sock, out.c_str(), out.size());
// }
// else
// {
// logmessage(NORMAL, "Client quit, break the link.");
// //n == 0服务端收不到数据了,证明客户端退出了,连接断了,客户端也断开连接
// break;
// }
// }
// }
//启动服务端进程,普通版本
// void start()
// {
// while(1)
// {
// struct sockaddr_in peer;//储存本地网络信息
// socklen_t len = sizeof(peer);
// int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
// //如果没有客户端连接服务端,则accept会阻塞等待新连接
// //如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
// if(sock < 0)
// {
// logmessage(ERROR, "accept fail");//接收新文件描述符失败
// continue;//重新回到头接收
// }
// logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
// //不断处理IO任务
// serviceIO(sock);
// //退出该函数时,客户端已经退出,需要将进行网络传输的文件描述符释放,否则会引起资源泄露
// close(sock);
// //服务端释放通信的文件描述符,但不释放监听的文件描述符
// //服务端进程可以继续使用该监听文件描述符阻塞等待下一个客户端连接
// }
// }
// //启动服务端进程,多进程版本
// void start()
// {
// while(1)
// {
// struct sockaddr_in peer;//储存本地网络信息
// socklen_t len = sizeof(peer);
// int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
// //如果没有客户端连接服务端,则accept会阻塞等待新连接
// //如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
// if(sock < 0)
// {
// logmessage(ERROR, "accept fail");//接收新文件描述符失败
// continue;//重新回到头接收
// }
// logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
// //创建子进程
// pid_t id = fork();
// if(id == 0)
// {
// //由于子进程也继承了父进程的监听套接字,而监听套接字只需要一个,所以需要关闭
// close(_listensock);
// if(fork() > 0)
// {
// //子进程再次创建子进程,fork对父进程(当前是服务器的子进程)返回子进程pid,对子进程(当前是服务器的孙子进程)返回0
// exit(0);//服务器子进程退出
// }
// //服务器的孙子进程执行IO处理
// //不断处理IO任务
// serviceIO(sock);
// //退出该函数时,客户端已经退出,需要将进行网络传输的文件描述符释放,否则会引起资源泄露
// close(sock);
// //退出孙子进程,由于它是孤儿进程,所以会被操作系统自行回收
// exit(0);
// }
// }
// }
//启动服务端进程,多线程版本,注意编译要加-lpthread
static void* thread_routine(void* args)
{
pthread_detach(pthread_self());//将该线程分离,执行完毕后操作系统自动回收
Threaddata* p = (Threaddata*)args;
p->_pdata->serviceIO(p->_sock);
//退出该函数时,客户端已经退出,需要将进行网络传输的文件描述符释放,否则会引起资源泄露
}
//先看这里
void start()
{
while(1)
{
struct sockaddr_in peer;//储存本地网络信息
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
//如果没有客户端连接服务端,则accept会阻塞等待新连接
//如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
if(sock < 0)
{
logmessage(ERROR, "accept fail");//接收新文件描述符失败
continue;//重新回到头接收
}
logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
//创建新线程
pthread_t tid;
Threaddata* p = new Threaddata(sock, this);
pthread_create(&tid, nullptr, thread_routine, p);//创建线程并开始执行
}
}
//启动服务端进程,线程池版本
void start()
{
//初始化线程池
ThreadPool<Task>::GetInstance()->run();
logmessage(NORMAL, "Thread init success");
while(1)
{
struct sockaddr_in peer;//储存本地网络信息
socklen_t len = sizeof(peer);
int sock = accept(_listensock, (struct sockaddr*)&peer, &len);
//如果没有客户端连接服务端,则accept会阻塞等待新连接
//如果有客户端需要连接服务端,则返回一个用于网络通信的描述符sock
if(sock < 0)
{
logmessage(ERROR, "accept fail");//接收新文件描述符失败
continue;//重新回到头接收
}
logmessage(NORMAL, "accept a new link");//接收新文件描述符成功
ThreadPool<Task>::GetInstance()->push(Task(sock, serviceIO));
}
}
//按道理不释放监听文件描述符也是一种资源泄漏,但是服务器进程大部分需要长期运行
//只有进程出问题了进程才会退出,而当进程退出时,它占用的资源也归还了操作系统,所以不释放也无所谓
~tcpserver()
{}
private:
uint16_t _port;//服务端进程的端口号
int _listensock;//监听文件描述符
};
server.cc
#include"log.hpp"
#include"server.hpp"
#include<memory>
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<tcpserver> p(new tcpserver(port));
p->initserver();
p->start();
return 0;
}
3.客户端
client.hpp
#pragma once
#include<iostream>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<errno.h>
#include<string.h>
#include<strings.h>
#include<istream>
#include<stdlib.h>
#include<stdio.h>
#include<memory>
#define NUM 1024
enum errorcode
{
USAGE_ERROR = 1,
SOCKET_ERROR,
BIND_ERROR,
CONNECT_ERROR
};
class tcpclient
{
public:
//构造函数
tcpclient(const std::string& ip, const uint16_t& port)
:_ip(ip)
,_port(port)
,_sock(-1)
{}
void initclient()
{
//创建套接字,创建失败打印错误原因
_sock = socket(AF_INET, SOCK_STREAM, 0);
if(_sock == -1)
{
logmessage(FATAL, "create socket error");//socket失败属于最严重的错误
exit(SOCKET_ERROR);//退出
}
logmessage(NORMAL, "create socket success:%d", _sock);//创建套接字成功,打印让用户观察到
//客户端不需要显式绑定,该工作交给操作系统完成
}
//启动客户端进程
void run()
{
struct sockaddr_in local;
local.sin_family = AF_INET;//通信方式为网络通信
local.sin_port = htons(_port);//将网络字节序的端口号填入
local.sin_addr.s_addr = inet_addr(_ip.c_str());//填充结构体
//客户端连接服务器
if(connect(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)//客户端与服务器进行连接
{
logmessage(FATAL, "connect error");//connect失败属于最严重的错误
}
else
{
std::string msg;
while(1)
{
//向服务器发送数据
printf("Please enter#");
getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
//接收服务器发回的数据
char buffer[1024];
int n = read(_sock, buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
printf("server return$%s\n", buffer);
}
else
{
//read返回0,证明服务端关闭,客户端也要关闭
break;
}
}
}
}
//析构函数要释放不使用的文件描述符
~tcpclient()
{
if( _sock >= 0)
close(_sock);
}
private:
int _sock;//套接字文件描述符
std::string _ip;//服务器IP地址
uint16_t _port;//服务器的端口号
};
client.hpp
#include"log.hpp"
#include"client.hpp"
using namespace std;
static void Usage(string proc)
{
printf("\nUsage:\n\t%s server_ip server_port\n\n", proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 3)//如果没输入端口号和目的IP,argc保存的命令参数就不是三个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[2]);
string ip = argv[1];
unique_ptr<tcpclient> p(new tcpclient(ip, port));
p->initclient();
p->run();
return 0;
}
运行结果:
五、守护进程
1.什么是守护进程
我们让服务器进程运行起来
打开另一个会话,使用netstat -lntp查看使用网络的进程网络信息
其中,第一个协议为tcp,IP第hi为0.0.0.0,端口号为8080的s进程就是我们的服务器进程。
此时我们关闭运行服务器进程的Xshell对话框,再从另一个没关闭的对话框内再次查看网络进程信息,此时发现我们的服务器进程不见了。
那问题就出现了,我只是关闭会话,并没让服务器进程退出,怎么服务器进程也没了。
实际上,我们每次在Xshell上创建一个会话,Linux机器上都会运行一个名字为bash的进程。每一个会话中允许拥有一个前台任务和多个后台任务。
当Xshell的窗口关闭后,Linux机器上对应的会话结束,bash进程退出,bash维护的所有进程也会退出。所以关掉Xshell窗口后tcpserve进程也会退出。
而服务器往往是需要长期运行的,我们总不能老开着Xshell吧。所以就需要让服务器进程成为另一个独立会话管理的进程,这样进程就不会被其他的会话打扰了。
这种进程叫做守护进程,也叫做精灵进程。
2.前后台进程组
上图中,sleep 10000 | sleep 20000 | sleep 30000 &是通过管道一起创建的3个后台进程,这三个进程组成一个进程组,每个进程组也被叫做一个作业(你可以理解为高空作业的那个作业,不是写的作业)。后面的&表示这个作业是后台进程。
使用指令jobs可以查看当前机器上的作业,比如下面就先创建了一个作业,jobs观察到一个作业。然后又创建了两个作业,此时有3个作业在运行,而且都是后台进程。
前面的数字是进程组的编号[1][2][3]。
输入指令fg+进程组编号,可以将后台进程变成前台进程,比如我们输入fg 1。此时第一个作业就变为了前端,进程Xshell窗口就阻塞住了,我们输入什么都不管用了。
使用Ctrl+Z可将该进程组暂停后,再次输入jobs可以看到进程组1后面的&没有了。这就表示它成为了前台进程,前面的stopped表示我们将其暂停了。
使用指令bg+进程组编号,可以将进程组设置入后台。此时进程组1后面的&又出现了,作业也运行了起来,也不再阻塞了,可以在窗口中继续输入指令了。
如果你查看sleep的进程,就能看到第二行后9个sleep进程的pid值都不同,这也证明它们都是独立的进程。
不过如果你仔细看第三行,还是会发现一些异同。
其中,第一行表示PPID也就是所有的9个sleep进程都是父进程bash创建的。
第二行表示PID,9个sleep进程各不一样。
第三行表示PGID,也就是进程组的ID,我用红绿蓝框框出的PGID相同,就证明它们属于同一个进程组,其中PID和PGID值相同的进程是这个进程组的组长。
第四行表示SID,是会话的ID,所有进程的SID都相同,同属于一个bash'会话。
3.将进程变为守护进程
系统调用setsid的作用就是将调用该函数的进程变成守护进程,也就是创建一个新的会话,这个会话中只有当前进程,但调用该系统调用的进程不能是进程组的组长。
如果调用成功,则返回新的会话SID,调用失败,则返回-1,并且设置错误码。
我们再应以一个daemonself就可以让我们的tcp服务器变成守护进程,总共分为四步。
(1)调用进程忽略异常信号
守护进程隶属于另一个独立的会话,它的具体运行情况我们这个会话是无从得知的。此时它就i有可能受到异常信号的干扰而退出,而我们不会知道。为了避免这样的情况,就要忽略掉异常信号,尤其是关于管道的信号,毕竟网络操作本质就是文件操作,所以使用signal系统调用忽略掉SIGPIPE信号。
(2)自己不是组长
setsid系统调用要求调用的进程不能是进程组的组长。所以我们就可以同样采用之前多进程的策略。窗口进程创建服务器进程,服务器创建新进程,将后续代码交给它的子进程。此时原本bash的组长就退出了,新的子进程成为了孤儿进程被操作系统收养,操作系统的到进程组内它也就不是组长了。
然后,子进程就可以调用setsid变成守护进程。所以我们也可以认为,守护进程本质上就是一个孤儿进程。
(3)关闭或者重定向以前进程默认打开的文件
在Linux中存在一个黑洞文件/dev/null,向该文件中写入的内容会被全部丢弃。从该文件中读取内容时,虽然什么也读不到,但不会发生错误。
每个进程都会默认打开文件描述符为0,1,2的三个文件,但守护进程是脱离终端的,并没有显示器、键盘等设备文件,所以要对这三个文件重定向到这个黑洞文件/dev/null。如果无法重定向,关闭这三个文件也行。
(4)进程执行路径发生更改(可选)
每一进程都有一个cwd数据,用来记录当前进程的所属路径,所以默认情况下,进程文件所在的路径就是当前目录。
这是我们查看bash进程的cwd。
成为守护进程后,如果需要更改服务器的执行路径,就可以通过系统调用chdir来改变cwd属性,从而更改路径。这里使用缺省值null。
最终我们的server.cc代码如下:
#include"log.hpp"
#include"server.hpp"
#include<memory>
#include<unistd.h>
#include<fcntl.h>
#define BLACKHOLE "/dev/null"
void daemonself(const char* cur_path = nullptr)
{
//忽略管道信号
signal(SIGPIPE, SIG_IGN);
//自己不能成为组长
if(fork()>0)
exit(0);//父进程退出
//子进程继续执行
pid_t n = setsid();//变为守护进程
assert(n != -1);
//重定向三个默认打开的文件
int fd = open(BLACKHOLE, O_RDWR);
if(fd > 0)
{
//打开成功,重定向
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
else
{
//打开不成功,直接关上
close(0);
close(1);
close(2);
}
//更改进程的执行路径(选做)
if(cur_path)
chdir(cur_path);
}
static void Usage(string proc)
{
printf("\nUsage:\n\t%s local_port\n\n",proc.c_str());
}
int main(int argc, char* argv[])
{
if(argc != 2)//如果没输入端口号,argc保存的命令参数只有一个,进程出错
{
Usage(argv[0]);
exit(USAGE_ERROR);
}
uint16_t port = atoi(argv[1]);
unique_ptr<tcpserver> p(new tcpserver(port));
p->initserver();
daemonself();
p->start();
return 0;
}
再次编译打开后,可以看到只有initserver的代码打印在了屏幕上,而变为守护进程后start应当打印的字符都消失了。
当你打开客户端时,即使关闭了运行服务器的对话框,服务端也还在继续运行,我们可以通过客户端进行通信。
此时我们只能通过kill -9 pid的方式终止该进程。
4.关于daemon的说明
在unistd.h的头文件中,已经有一个系统调用daemon可以让一个进程变成守护进程。
int daemon(int nochdir, int noclose);
但是它不好用。在实际应用中,人们大多通过setsid自己实现daemon,就像我们上面做的一样。