网络编程套接字( TCP )
Posted 三分苦
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了网络编程套接字( TCP )相关的知识,希望对你有一定的参考价值。
目录
1、实现一个TCP网络程序(单进程版)
1.1、服务端serverTcp.cc文件
我们把服务器封装成一个ServerTcp类,该类里主要有如下几个任务:
- 服务端创建套接字
- 服务端绑定
- 服务端监听
- 服务端获取链接
- 服务端提供服务
- 服务端main函数命令行参数
下面依次演示:
服务端创建套接字
我们把服务器封装成一个ServerTcp类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器首先要创建套接字。创建套接字的函数叫做socket函数,再回顾下其函数原型:
int socket(int domain, int type, int protocol);
这里TCP服务器在调用socket函数创建套接字时,参数设置如下:
- domain:协议家族选择AF_INET,因为我们要进行的是网络通信。
- type:创建套接字时所需的服务器类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。注意我UDP是用户数据报服务。
- protocol:协议类型默认设置为0即可。
若socket创建失败,则复用logMessage函数打印相关日志信息,并直接exit退出程序。
class ServerTcp public: // 构造函数 + 析构函数 public: // 初始化 void init() // 1、创建socket sock_ = socket(AF_INET, SOCK_STREAM, 0); if (sock_ < 0) logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志 exit(SOCKET_ERR); logMessage(DEBUG, "socket: %s, %d", strerror(errno), sock_); private: int sock_; // socket uint16_t port_; // port string ip_; // ip ;
服务端绑定
- 当套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。所以我们需要调用bind函数进行绑定操作。
绑定的步骤如下:
- 1、绑定网络信息,先填充基本信息到struc sockaddr_in结构体。
- 定义struc sockaddr_in结构体对象local,复用memset函数对local进行初始化。将协议家族、端口号、IP地址等信息填充到该结构体变量当中。注意协议家族这里设定的是PF_INET。
- 服务器的端口号是要发给对方的,在发送到网络之前要复用htons主机转网络函数把端口号port_转成网络序列,才能向外发送。
- ip地址默认是字符串风格点分十进制的,这里复用inet_aton函数将字符串IP转换成整数IP(inet_addr除了做转换,还会自动给我们做主机转网络)。注意若ip地址是空的,那就用INADDR_ANY这个宏,否则再用inet_addr函数。这个宏就是0,因此在设置时不需要进行网络字节序的转换。
- 2、绑定网络信息,上述local临时变量(struc sockaddr_in结构体对象)是在用户栈上开辟的,要将其写入内核中。复用bind函数完成绑定操作。bind成功与否均复用logMessage函数打印相关日志信息。
- 由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in*强转为struct sockaddr*类型后再进行传入。
class ServerTcp public: // 构造函数 + 析构函数 public: // 初始化 void init() // 1、创建socket // 2、bind绑定 // 2.1、填充服务器信息 struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、将本地socket信息,写入sock_对应的内核区域 if (bind(sock_, (const struct sockaddr *)&local, sizeof(local)) == -1) logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志 exit(BIND_ERR); logMessage(DEBUG, "bind: %s, %d", strerror(errno), sock_); private: int sock_;// socket uint16_t port_; // port string ip_; // ip ;
服务端监听
listen接口说明
- UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
- 因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
设置套接字为监听状态的函数叫做listen,该函数的函数原型如下:
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
- 监听成功返回0,监听失败返回-1,同时错误码会被设置。
代码逻辑如下
- TCP是面向连接的,所以要让TCP服务器时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。监听失败就打印日志信息,并直接退出。因为监听失败就意味着TCP服务器无法接受客户端发来的连接请求。
class ServerTcp public: // 构造函数 + 析构函数 public: // 初始化 void init() // 1、创建socket // 2、bind绑定 // 3、监听socket if (listen(sock_, 5) < 0) logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志 exit(LISTEN_ERR); logMessage(DEBUG, "listen: %s, %d", strerror(errno), sock_); private: int sock_; // socket uint16_t port_; // port string ip_; // ip ;
初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock_改为listensock_。
服务端获取连接
accept接口说明
- TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。究竟是谁连接我的。
获取连接的函数叫做accept,该函数的函数原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数说明:
- sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。
返回值说明:
- 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。
调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。监听套接字与accept函数返回的套接字的作用:
- 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
- accept函数返回的套接字:用于为本次accept获取到的连接提供服务(为用户提供网络服务,主要是进行IO)。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字。
代码逻辑如下
- 定义struct sockaddr_in的对象peer,定义len为peer的字节数
- 复用accept函数获取连接。若返回值<0说明连接失败,但是TCP服务器不会因为某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
- 获取连接成功后,要获取客户端的基本信息,将客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
class ServerTcp public: // 构造函数 + 析构函数 public: // 初始化 void init() // 1、创建socket // 2、bind绑定 // 3、监听socket // 启动服务端 void loop() while (true) // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); private: int listensock_;// socket uint16_t port_; // port string ip_; // ip ;
服务端接受连接测试
- 这里我们客户端还没有写,但是我们可以先允许服务端,然后在windows下的浏览器上用当前云服务器ip(124.71.25.237)+端口号(8080)进行访问测试
- 浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。测试如下:
注意:
- 至于这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接。
服务端提供服务
read接口说明
- 现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
TCP服务器读取数据的函数叫做read,该函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
read返回值为0表示对端连接关闭。这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
write接口说明
- TCP服务器写入数据的函数叫做write,该函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
代码逻辑如下
- 注意:服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
- 这里我们把服务端提供服务的过程封装成一个transService函数,其内部完成的主要功能是完成大小写转化
- 首先,调用read函数读取客户端发来的数据,这里且假定读取的是字符串。read函数返回值为s。
- 若返回值s > 0,说明读取成功,在内部首先调用strcasecmp函数判断客户端是否需要服务端提供服务,若不需要(quit),则打印日志并退出,若需要,在内部完成大小写转化的功能。转化完成后调用write函数将结果返回给客户端
- 若返回值s = 0或s < 0,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
class ServerTcp public: // 构造函数 + 析构函数 public: // 初始化 void init() // 1、创建socket // 2、bind绑定 // 3、监听socket // 启动服务端 void loop() while (true) // 4、获取连接 // 4.1、获取客户端基本信息 // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 transService(serviceSock, peerIp, peerPort); // 大小写转化服务 // TCP && UDP: 支持全双工 void transService(int sock, const string &clientIp, uint16_t clientPort) assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串 if (s > 0) // 读取成功 inbuffer[s] = '\\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数 // 客户端输入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以进行大小写转化了 for (int i = 0; i < s; i++) if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 进行写回操作 write(sock, inbuffer, strlen(inbuffer)); else if (s == 0) // 对方关闭 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; else // 读取出错 logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; // 只要走到这里,一定是client退出了,服务到此结束 close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip ;
服务端main函数命令行参数
将来我们的服务端在启动的时候,在命令行中一定是按照如下格式输入的:
./ServerTcp local_port local_ip
我们需要给main函数加上命令行参数,内部代码逻辑如下:
- 利用命令行参数的形式,若main函数中argc != 2 && argc != 3,则复用提示信息函数Usage,并exit退出进程
- 定义port端口为命令行的第二个参数(下标为1的参数)
- 若argc == 3,则定义ip地址为命令行的第三个参数(下标为2的参数)
- 将端口号和ip地址传入ServerTcp服务器的类里,调用init和start函数
static void Usage(string proc) cerr << "Usage:\\n\\t" << proc << "port ip" << endl; cerr << "Example:\\n\\t" << proc << "8080 127.0.0.1\\n" << endl; // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) if (argc != 2 && argc != 3) Usage(argv[0]); exit(USAGE_ERR); uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0;
服务端serverTcp.cc总代码
ServerTcp类的成员变量如下:
- listensock_
- port_
- ip_
ServerTcp类的成员函数如下:
- ServerTcp构造函数
- ServerTcp析构函数
- init初始化函数
- loop启动服务器函数
总代码如下:
#include "utli.hpp" class ServerTcp public: ServerTcp(uint16_t port, const string &ip = "") : port_(port), ip_(ip), listensock_(-1) ~ServerTcp() public: // 初始化 void init() // 1、创建socket listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) logMessage(FATAL, "socket: %s", strerror(errno)); // 创建失败,打印日志 exit(SOCKET_ERR); logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_); // 2、bind绑定 // 2.1、填充服务器信息 struct sockaddr_in local; // 用户栈 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、将本地socket信息,写入listensock_对应的内核区域 if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1) logMessage(FATAL, "bind: %s", strerror(errno)); // 绑定失败,打印日志 exit(BIND_ERR); logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_); // 3、监听socket if (listen(listensock_, 5) < 0) logMessage(FATAL, "listen: %s", strerror(errno)); // 监听失败,打印日志 exit(LISTEN_ERR); logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_); // 允许别人连接你了 // 启动服务端 void loop() while (true) // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 transService(serviceSock, peerIp, peerPort); // 大小写转化服务 // TCP && UDP: 支持全双工 void transService(int sock, const string &clientIp, uint16_t clientPort) assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我们认为读取到的都是字符串 if (s > 0) // 读取成功 inbuffer[s] = '\\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小写比较的函数 // 客户端输入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以进行大小写转化了 for (int i = 0; i < s; i++) if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 进行写回操作 write(sock, inbuffer, strlen(inbuffer)); else if (s == 0) // 对方关闭 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; else // 读取出错 logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; // 只要走到这里,一定是client退出了,服务到此结束 close(sock); // 如果一个进程对应的文件fd,打开了没有被归还,则文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip ; static void Usage(string proc) cerr << "Usage:\\n\\t" << proc << "port ip" << endl; cerr << "Example:\\n\\t" << proc << "8080 127.0.0.1\\n" << endl; // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) if (argc != 2 && argc != 3) Usage(argv[0]); exit(USAGE_ERR); uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0;
1.2、客户端clientTcp.cc文件
这里我们不像服务端udpServer.cc一样进行封装成类了。其内部主要框架逻辑如下:
- main函数采用命令行参数
- 客户端创建套接字
- 通讯过程(启动客户端)
下面依次演示
客户端main函数命令行参数
客户端在启动的时候必须要知道服务端的ip和port,才能进行连接服务端。未来的客户端程序一定是这样运行的:
./clientTcp serverIp serverPort
- 如果命令行参数个数argc != 3,复用Usage函数输出相关提示信息,并退出程序
- 定义string类型的serverIp变量保存命令行的第二个参数
- 定义serverPort变量保存命令行中的第三个参数
static void Usage(string proc) cerr << "Usage:\\n\\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\\n\\t" << proc << "127.0.0.1 8080\\n" << endl; // ./clientTcp serverIp serverPort int main(int argc, char* argv[]) if (argc != 3) Usage(argv[0]); exit(USAGE_ERR); string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); return 0;
客户端创建套接字
客户端创建套接字时选择的协议家族也是AF_INET,需要的服务类型也是SOCK_STREAM。与服务端不同的是,客户端在初始化时只需要创建套接字就行了,而不需要进行绑定操作。
int main() ... // 1、创建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); ... close(sock); return 0;
客户端的bind、listen、accept问题
客户端需不需要自己进行bind绑定呢?
- 不需要。所谓的“不需要”,指的是:客户端不需要用户自己bind端口信息!因为OS会自动给你绑定。(这个问题和udp的一样)
客户端需不需要自己进行listen监听呢?
- 不需要。监听本来就是等着别人来连你,作为客户端,你是要主动连接别人的,而不是等着服务端自动向你连接的,这属实反客为主了。
- 而服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。
客户端需不需要自己进行accept获取呢?
- 不需要,因为都没有listen,都没有人来连你,当然不用accpet
客户端连接服务器
connect接口说明
- 客户端创建完套接字后需要向服务器发送链接请求。发起连接请求的函数叫做connect,该函数的函数原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
代码逻辑如下
- 定义struct sockaddr_in类型的结构体指针server,复用memset函数对其清零
- 填写服务器对应的信息,将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
- 注意要复用htons主机转网络函数把端口号转成网络序列,才能向外发送。
- 注意要复用inet_aton函数将字符串IP转换成整数IP
- 复用connect函数向服务器发送连接请求
int main(int argc, char *argv[]) // 1、创建socket // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); cout << "info: connect success: " << sock << endl; return 0;
客户端发起请求
- 由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。
- 当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。
int main(int argc, char *argv[]) // 1、创建socket // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); cout << "info: connect success: " << sock << endl; string message; while (!quit) message.clear(); cout << "请输入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; else if (s <= 0) break; return 0;
客户端clinetTcp.cc总代码
clientTcp.cc文件的内部主要框架逻辑如下:
- main函数使用命令行参数:
- 客户端创建套接字
- 连接过程
总代码如下:
#include "utli.hpp" volatile bool quit = false; static void Usage(string proc) cerr << "Usage:\\n\\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\\n\\t" << proc << "127.0.0.1 8080\\n" << endl; // ./clientTcp serverIp serverPort int main(int argc, char *argv[]) if (argc != 3) Usage(argv[0]); exit(USAGE_ERR); string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); // 1、创建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); // 2、connect, 向服务器发起连接请求 // 2.1、先填充需要连接的远端主机的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、发起请求,connect 回自动帮我们进行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); cout << "info: connect success: " << sock << endl; string message; while (!quit) message.clear(); cout << "请输入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; else if (s <= 0) break; close(sock); return 0;
1.3、服务器测试
现在服务端和客户端均已写好,先运行服务端,再运行客户端。我们使用如下指令辅助我们观察现象:
[xzy@ecs-333953 tcp]$ sudo netstat -ntp | grep -E 'serverTcp|clientTcp'
如上我服务器的端口是8081,它已经和端口43914的客户端相互建立起了连接:
现在就可以让客户端向服务端发送消息了,当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。
当我客户端发送quit退出动作时,服务端识别后,确认客户端退出,并关闭对应的socket。如果我强制ctrl -c退出客户端,OS会自动帮我们关掉对应的文件描述符,此时服务端也就知道客户端退出了,进而会终止对客户端的服务。
1.4、单执行流服务器的问题
当我们仅用一个客户端连接服务器时,这一个客户端能够正常享受到服务端的服务:
但当此客户端1正常享受服务端的服务时,我们让另一个客户端2也连接此服务器, 此时发现两个客户端都是可以正常连接的,但是客户端2发给服务端的消息并没有在服务端进行打印,服务端也没有将该数据回显给客户端2。相反我客户端1和服务端是能够正常通信的:
但是当客户端1退出后,服务端才将客户端2发来的数据进行打印,并回显到客户端2上:
单进程的服务器
- 通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务,一旦进入transService函数,主执行流就无法进行向后执行,只能提供完毕服务之后才能进行accept。
- 当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
解决办法
- 单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程。
2、多进程版的TCP网络程序
- 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
- 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕
- 父进程创建的子进程会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。
阻塞式等待与非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。不等待子进程退出的方式如下:
- 捕捉SIGCHLD信号,将其处理动作设置为忽略。
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。
捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
class ServerTcp public: // 构造 + 析构 public: // 初始化 // 启动服务端 void loop() signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多进程 pid_t id = fork(); assert(id != -1); if (id == 0) close(listensock_); // 建议关掉 // 子进程 transService(serviceSock, peerIp, peerPort); exit(0); // 子进程退出进入僵尸 // 父进程 close(serviceSock); // 一定要做 private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip ;
测试结果:
我们使用如下的监控脚本辅助我们观察现象:
[xzy@ecs-333953 tcp]$ ps -axj | head -1 && ps axj | grep serverTcp
- 当我们让客户端1连接服务器后,服务器进程会调用fork函数创建出一个子进程并提供服务;当我们让客户端2连接服务器后,服务器进程同样会调用fork函数创建出一个子进程并提供服务。所以我们会看到3个进程在运行的状态:
- 如下我们还应该看到客户端1和客户端2各自向服务端发送信息,且都能正常收到服务端的回复。
现在我们让客户端一个一个退出,并用如下的监控脚本观察进程数量的变化:
[xzy@ecs-333953 tcp]$ while :; do ps -axj | head -1 && ps axj | grep serverTcp ; sleep 1 ;done
当客户端一个一个推出后,服务端为之提供的子进程也会相机退出,单无论如何服务端都至少会有一个服务进程,此进程的任务就是不断获取新连接。
让孙子进程提供服务
我们可以让服务端冲断爷爷进程,服务端创建的子进程(爸爸进程)继续fork创建子进程(孙子进程),让孙子进程为客户端提供服务,过程如下:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程。
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
不需要等待孙子进程退出
- 我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
- 而由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的。
关闭对应的文件描述符
- 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
- 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
代码如下:
class ServerTcp public: // 构造 + 析构 public: // 初始化 // 启动服务端 void loop() signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信号 while (true) // 4、获取连接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 获取连接失败 continue; // 4.1、获取客户端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服务,echo ( 小写 -> 大写 ) // 5.0 v0版本 —— 单进程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多进程 ———— 捕捉SIGCHLD信号 // 5.1 v1.1版本 —— 多进程 ———— 让孙子进程提供服务 // 爷爷进程 pid_t id = fork(); if (id == 0) // 爸爸进程 close(listensock_); // 建议关掉 if (fork() > 0) // 又进行了一次fork,让爸爸进程直接终止 exit(0); // 孙子进程 ———— 没有爸爸 ———— 孤儿进程 ———— 被系统领养 ———— 回收问题就交给了系统来回收 transService(serviceSock, peerIp, peerPort); exit(0); close(serviceSock); // 一定要做 // 爸爸进程直接终止,立马得到退出码,释放僵尸状态 pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式 assert(ret > 0); (void)ret; private: int listensock_; // 监听套接字socket uint16_t port_; // port string ip_; // ip ;
测试结果:
3、多线程版的TCP网络程序
- 创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
- 当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
文件描述符关闭的问题:
由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
- 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
- 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。
代码逻辑如下:
- 我们使用pthread_create创建线程,让新线程内部执行为客户端提供服务transService的操作。所以我们需要在线程执行函数threadRoutine里传入客户端ip,port,sock。
- 为了能够让线程执行函数threadRoutine获得ip,port,sock这三个参数,我们在pthread_create的最后一个参数传入一个ThreadData结构体,该结构体内部包含了这三个
UNIX网络编程笔记—基本TCP套接字编程
基本TCP套接字编程
主要介绍一个完整的TCP客户/服务器程序需要的基本套接字函数。
1.概述
在整个TCP客户/服务程序中,用到的函数就那么几个,其整体框图如下:
2.socket函数
为了执行网络I/O,一个进程必须要做的事情就是调用socket函数。其函数声明如下:
#include <sys/socket.h> int socket(int family ,int type, int protocol);
其中:
family:指定协议族
type:指定套接字类型
protocol:指定某个协议,设为0,以选择所给定family和type组合的系统默认值。这些参数有一些特定的常值定义如下:
faimly 说明 AF_INET IPv4协议 AF_INET6 IPv6协议 AF_LOCAL Unix域协议 AF_ROUTE 路由套接字 AF_KEY 密钥套接字 表1 socket函数的family常值
type 说明 SOCK_STREAM 字节流套接字 SOCK_DGRAM 数据报套接字 SOCK_SEQPACKET 有序分组套接字 SOCK_RAW 原始套接字 表2 socket函数的type常值
protocol 说明 IPPROTO_TCP TCP传输协议 IPPROTO_UDP UDP传输协议 IPPROTO_SCTP SCTP传输协议 表3 socket函数AF_INET或AF_INET6的protocol常值
socket函数调用成功的时候将返回一个小的非负整数值,成为套接字描述符,简称sockfd。为了得到这个描述符,我们制定了协议族和套接字类型,并未指定本地与远程协议地址。
另外,书中还提到一个AF_XXX(表示地址族)和PF_XXX(表示协议族)的区别,一般情况下都使用AF,知道这个就可以了。
3.connect函数
函数声明如下:
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *servaddr,socklen_t addrlen);
sockfd:由socket返回的套接字描述符。
servaddr:套接字地址结构,包含了服务器IP和端口号。
addrlen:套接字地址结构大小,防止读越界。客户端调用connect时,将向服务器主动发起三路握手连接,直到连接建立和连接出错时才会返回,这里出错返回的可能有一下几种情况:
1)TCP客户没有收到SYN分节的响应。(内核发一个SYN若无响应则等待6s再发一个,若仍无响应则等待24s后再发送一个。总共等待75s仍未收到则返回错误ETIMEDOUT)
2)若对客户的SYN的响应是RST,表明服务器主机在我们指定的端口上没有进程在等待与之连接,客户端收到RST就会返回ECONNREFUSED错误。
产生RST的三个条件是:目的地SYN到达却没有监听的服务器;TCP想取消一个已有连接;TCP接收到一个根本不存在连接上的分节。
3)若客户发出的SYN在中间的某个路由器上引发了一个“destination unreachable”(目的地不可达)ICMP错误,则认为是一种软错误,在某个规定时间(比如上述75s)没有收到回应,内核则会把保存的信息作为EHOSTUNREACH或ENETUNREACH错误返回给进程。若connect失败则该套接字不再可用,必须关闭,我们不能对这样的套接字再次调用connect函数,当循环调用函数connect为给定主机尝试各个ip地址直到有一个成功时,在每次connect失败后,都必须close当前的套接字描述符并从新调用socket。
4.bind函数
bind函数把一个本地协议地址赋予了一个套接字。协议地址时32位IPv4地址或128位的IPv6地址与16位的TCP/UDP端口号的组合。
在调用bind函数可以制定一个特定的端口号,或者制定一个IP地址,或者两个都指定,后者两者都不指定。
函数声明如下:
#include <sys/socket.h> int bind(int sockfd, const struct sockaddr * myaddr,socklen_t addrlen);
参数说明:
sockfd:套接字描述符
myaddr:套接字地址结构的指针
addrlen:上述结构的长度,防止内核越界服务器在启动时捆绑它们的众所周知的端口,例如时间获取服务的端口13。如果不调用bind函数,当调用connect或listen的时候,TCP会创建一个临时的端口,这对于客户端来说很常见(毕竟我们从来没见过客户端程序调用过bind函数),而对于TCP服务器来说就比较少见了,因为TCP服务器就是通过其众所周知的端口被大家认识。
进程可以把一个特定的IP地址绑定到它的套接字上:对于客户端来说,这没有必要,因为内核将根据所外出网络接口来选择源IP地址。对于服务器来说,这将限定服务器只接收目的地为该IP地址的客户连接。
对于IPv4来说,通配地址由常值
INADDR_ANY
来指定,其值一般为0,它告知内核去选择IP地址,因此我们经常看到如下语句:
struct sockaddr_in servaddr; servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
同理,端口号指定为0时,内核就在bind被调用的时候选择一个临时端口,不过bind函数不返回内核选择的值,第二个参数有const限定。如果想要直到内核所选择的临时端口值,必须调用
getsockname
来返回协议地址。最后需要注意的是:bind绑定保留端口号时需要超级用户权限。这就是为什么我们在linux下执行服务器程序的时候要加
sudo
,如果没有超级用户权限,绑定将会失败。
5.listen函数
listen函数由TCP服务器调用,其函数声明如下:
#include <sys/socket.h> int listen (int sockdfd , int backlog);
listen函数主要有两个作用:
1.对于参数sockfd来说:当socket函数创建一个套接字时,它被假设为一个主动套接字。listen函数把该套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。
2.对于参数backlog:规定了内核应该为相应套接字排队的最大连接数=未完成连接队列+已完成连接队列
其中:
未完成连接队列:表示服务器接收到客户端的SYN但还未建立三路握手连接的套接字(SYN_RCVD状态)
已完成连接队列:表示已完成三路握手连接过程的套接字(ESTABLISHED状态)结合三路握手的过程:
1.客户调用connect发送SYN分节
2.服务器收到SYN分节在未完成队列建立条目
3.直到三鹿握手的第三个分节(客户对服务器SYN的ACK)到达,此时该项目从未完成队列移动到已完成队列的队尾。
4.当进程调用accept时,已完成队列出队,当已完成队列为空时,accept函数阻塞,进程睡眠,直到已完成队列入队。所以说,如果三路握手正常完成,未完成连接队列中的任何一项在其中存留的时间就是服务器在收到客户端的SYN和收到客户端的ACK这段时间(RTT)。
如图所示:
对于一个WEB服务器来说,RTT是187ms。
关于这两个队列还有需要注意的地方:当客户端发送SYN分节到达服务器时,如果此时服务器的未完成连接队列是满的,服务器将忽略这个SYN分节,服务器不会立即给客户端回应一个RST,因为客户端有自己的重传机制,如果服务器发送RST,那么客户度端的connect就会返回错误了。另外客户端无法区别RST究竟意味着“该端口没有服务器在监听”还是意味着“该端口有服务器在监听不过它的队列满了。”
6.accept函数
TCP服务器调用accept函数,函数声明如下:
#include<sys/socket.h> int accept (int sockfd, struct sockaddr *cliaddr ,socklen_t * addrlen);
参数说明:
sockfd:套接字描述符
cliaddr:对端(客户)的协议地址
addr:大小当accept调用成功,将返回一个新的套接字描述符,例如:
int connfd = Accept(listenfd,(SA*)NULL,NULL);
其中我们称
listenfd
为监听套接字描述符,称connfd
为已连接套接字描述符。,区分这两个套接字十分重要,一个服务器进程通常只需要一个监听套接字,但是却能够有很多已连接套接字(比如通过fork创建子进程),也就是说每有一个客户端建立连接时就会创建一个connectfd,当连接结束时,相应的已连接套接字就会被关闭。通过指针我们可以得到客户端的套接字信息,但是如果我们对这些不感兴趣就可以另他们为NULL,书中给出一个示例,服务器相应连接后,打印客户端的IP地址和端口号。
部分代码如下:
#include "unp.h" #include <time.h> int main(int argc, char **argv) { //... for ( ; ; ) { len = sizeof(cliaddr); connfd = Accept(listenfd, (SA *) &cliaddr, &len);//已连接套接字 //cliaddr获取客户端协议地址信息。 printf("connection from %s, port %d\\n", Inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)), ntohs(cliaddr.sin_port)); //... Close(connfd); } }
7.fork和exec函数
用fork创建子进程的方法并不陌生,这里将用fork编写并发服务器程序。
#include <unisted.h> pid_t fork(void);
在子进程中返回0,在父进程返回返回子进程ID,因为有这个父子的概念,所以fork函数调用一次却要返回两次。
关于fork函数的一些特性:
1.任何子进程只能有一个父进程,并且子进程可以通过getppid获取父进程ID
2.父进程中调用fork之前打开的描述符,在fork之后与子进程分享,在网络通信中也正是应用到这个特性。说到上述第2个特性,我们知道服务器进程往往在死循环中等待客户端连接,利用特性2,当accept函数调用返回一个connfd时,子进程可以利用其进行读写,而父进程直接关闭即可。
简而言之:父进程创建描述符,子进程对其实际操作。
exec函数实际上是6个函数,他们的区别主要在于:
(a)待执行的程序文件是由文件名还是路径名指定。
(b)新程序的参数是一一列出来还是由一个指针数组来引用
(c)把调用进程的环境传递给新程序还是给新程序指定新的环境。
关于EXEC的详情可参考: linux下c语言编程exec函数使用
8.并发服务器
首先一个概念叫做
“迭代服务器”
,例如:
for(;;) { connfd = Accept(listenfd,(SA*)NULL,NULL); ticks=time(NULL); snprintf(buff,sizeof(buff),"%.24s\\r\\n",ctime(&ticks)); Write(connfd,buff,strlen(buff)); Close(connfd); }
当一个客户端连接过来时,服务器向客户端写入时间信息后,关闭已连接套接字,回到for循环顶部阻塞等待连接的到来,这样每次连接到来的时候,必须完成该次服务,因为它占用了服务器进程。但是由于简单的获取时间服务本身就很快,单次服务马上就完成了,所以也就影响不大,不过如果是十分耗时的服务就不一定了,我们并不希望服务器被单个客户长时间占用,而是希望服务器同时服务多个用户,于是在Unix中编写并发服务器程序最简单的办法就是fork一个子进程来服务每个客户。
pid_t pid; int listenfd; int connfd; listenfd=Socket(/*...*/); Bind(listenfd,/*...*/); Listen(listenfd,/*...*/); for(;;) { connfd = Accept(listenfd,(SA*)NULL,NULL); if((pid=fork())==0)//子进程 { close(listenfd);//关闭监听套接字 doit(connfd);//服务 close(connfd); exit(0); } close(connfd); }
这里我一直不理解的是,为什么在子进程里面要关闭监听套接字(listenfd)呢?
这就跟fork的相关知识有关:1.首先fork并不是把父进程从头到尾执行一遍,否则这样不就无穷尽了。
2.父进程在调用fork处,整个父进程空间会原模原样地复制到子进程中,包括指令,变量值,程序调用栈,环境变量,缓冲区,等等。
3.在并发服务器的示例中,子进程会将已连接的套接字(connfd)和监听套接字(listenfd)拷贝到自己的进程空间。
4.对于套接字描述符来说,他们都有一个引用计数,fork之后由于描述符被复制,其引用计数都变成了2。
5.因此,我们在父进程中关闭connfd(因为我们在子进程中使用connfd),在子进程中关闭listenfd(因为我们在父进程中进行监听),此时他们的引用计数都变成了1。
6.然后,我们所期望的状态就是父进程中保留一个监听套接字继续等待客户端连接,在子进程中通过已连接的套接字对客户端请求进行服务。
7.最后在子进程中关闭connfd,或exit(0)
,使得connfd真正完成清理和资源释放。
9.close函数
close函数可以用来关闭套接字并终止TCP连接。
#include <unistd.h> int close(int sockfd);
从上节的并发服务器可以看到,close函数是对套接字描述符的引用计数减1,也就是说,如果调用close后,引用计数不为0,将不会引起TCP的四分组连接终止序列,这正是父进程与子进程共享已连接套接字的并发服务器所期望的。不过如果我们确实想在TCP连接上发送一个FIN,那么调用
shutdown
函数。
10.getsockname和getpeername函数
#include <sys/socket.h> int getsockname(int sockfd,struct sockaddr*localaddr,socklen_t *addrlen); int getpeername(int sockfd,struct sockaddr*peeraddr,socklen_t *addrlen); //若成功则为0,失败则为-1
这两个函数的作用:
1.首先我们知道在TCP客户端一般不使用bind函数,当connect返回后,getsockname
可以返回客户端本地IP地址和本地端口号。
2.如果bind绑定了端口号0(内核选择),由于bind的参数是const型的,因此必须通过getsockname
去得到内核赋予的本地端口号。
3.获取某个套接字的地址族
4.以通配IP地址bind的服务器上,accept成功返回之后,getsockname
可以用于返回内核赋予该连接的本地IP地址。其中套接字描述符参数必须是已连接的套接字描述符。
11.总结
本章介绍了套接字编程的函数,客户端和服务器端都从
socket
开始,客户端随后调用connect
,而服务器端先后调用bind
,listen
和accept
,最后使用close
来关闭描述符。以上是关于网络编程套接字( TCP )的主要内容,如果未能解决你的问题,请参考以下文章