在实现了简单的TCP服务器后,最开始我们实现的是单执行流的TCP服务器,之后通过代码测试发现单执行流的TCP服务器无法同时为多个客户端提供服务,于是又转而实现了多执行流的TCP服务器。
在实现多执行流的TCP服务器时,分别演示了多进程和多线程的实现方式,为了进一步优化基于多线程的TCP服务器,最终还将线程池接入到了TCP服务器当中。此时访问TCP服务器的各个客户端,分别由不同的执行流为其提供服务,因此这些客户端能够同时享受服务器提供的服务。
当时我们说过,如果想要让这里的TCP服务器处理其他任务,只需要修改对应的处理函数即可。对应到最终实现的线程池版本的TCP服务器,我们要修改的其实就只是任务类当中的handler方法。下面我们以实现简单的TCP英译汉服务器为例,看看更改后我们的TCP服务器能否正常为客户端提供英译汉服务。
英译汉TCP服务器要做的就是,根据客户端发来的英文单词找到其对应的中文意思,然后将该中文意思作为响应数据发给客户端。
之前我们是以回调的方式处理任务的,当线程池当中的线程从任务队列中拿出一个任务后,会调用该任务对应的Run方法处理该任务,而实际在这个Run方法当中会以仿函数的方式调用handler方法,因此我们只需更改Handler类当中对()的重载函数即可,而其他与通信相关的代码我们一律不用更改。
英译汉时需要根据英文单词找到其对应的中文意思,因此我们需要建立一张映射表,其中英文单词作为映射表中的键值key,而中文意思作为与键值相对应的value,这里可以直接使用C++STL容器当中的unordered_map容器。
class Handler
{
public:
Handler()
{}
~Handler()
{}
void operator()(int sock, std::string client_ip, int client_port)
{
//only for test
std::unordered_map<std::string, std::string> dict;
dict.insert(std::make_pair("dragon", "龙"));
dict.insert(std::make_pair("blog", "博客"));
dict.insert(std::make_pair("socket", "套接字"));
char buffer[1024];
std::string value;
while (true){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){ //读取成功
buffer[size] = '\0';
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
std::string key = buffer;
auto it = dict.find(key);
if (it != dict.end()){
value = it->second;
}
else{
value = key;
}
write(sock, value.c_str(), value.size());
}
else if (size == 0){ //对端关闭连接
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{ //读取失败
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); //归还文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
};
说明一下:
现在这个TCP服务器就能够给客户端提供英译汉的服务了,客户端发来的单词如果能够在映射表当中找到,那么服务端会将该单词对应发中文意思响应给客户端,否则会将客户端发来的单词原封不动的响应给客户端。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GxmRF9h6-1692352744258)(https://cdn.nlark.com/yuque/0/2023/png/29339358/1692351352525-dbeccd51-a12f-4447-a253-2e0034a2df4b.png#averageHue=%23181616&clientId=uea0765a4-4936-4&from=paste&height=363&id=ufd9b414e&originHeight=454&originWidth=1895&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=85142&status=done&style=none&taskId=u6f9b11d7-0149-4a5f-8149-a09d24ec622&title=&width=1516)]
注意: 这里只是想告诉大家,我们可以通过改变这里的handler方法来改变服务器处理任务的逻辑。如果你还想对当前这个英译汉服务器进行完善,可以在网上找一找牛津词典对应的文件,将其中单词和中文的翻译做出kv的映射关系,当服务器启动的时候就可以加载这个文件,建立对应的映射关系,此时就完成了一个网络版的英译汉服务器。
inet_ntoa函数
inet_ntoa函数的函数原型如下:int inet_aton(const char *cp, struct in_addr *inp);
参数说明:
返回值说明:
inet_addr函数
inet_addr函数的函数原型如下:in_addr_t inet_addr(const char *cp);
参数说明:
返回值说明:
inet_pton函数
inet_pton函数的函数原型如下:int inet_pton(int af, const char *src, void *dst);
参数说明:
返回值说明:
inet_ntoa函数
inet_ntoa函数的函数原型如下:char *inet_ntoa(struct in_addr in);
参数说明:
返回值说明:
inet_ntop函数
inet_ntop函数的函数原型如下:const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数说明:
返回值说明:
说明一下
inet_ntoa函数可以将四字节的整数IP转换成字符串IP,其中该函数返回的这个转换后的字符串IP是存储在静态存储区的,不需要调用者手动进行释放,但如果我们多次调用inet_ntoa函数,此时就会出现数据覆盖的问题。
例如,下列代码连续调用了两次inet_ntoa函数。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WcaEwXrM-1692352744259)(https://cdn.nlark.com/yuque/0/2023/png/29339358/1692351769395-eea788d8-7ac4-40ab-b64f-8cdc48433cc2.png#averageHue=%23201f1f&clientId=uea0765a4-4936-4&from=paste&height=440&id=uc872b8ef&originHeight=550&originWidth=1116&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=60024&status=done&style=none&taskId=uadb6976b-a983-48c3-860d-1f939a8a8a7&title=&width=892.8)]
由于inet_ntoa函数内部只在静态存储区申请了一块区域,因此inet_ntoa函数第二次转换的结果就会覆盖第一次转换的结果。
因此,如果需要多次调用inet_ntoa函数,那么就要及时保存inet_ntoa的转换结果。
并发场景下的inet_ntoa函数
inet_ntoa函数内部只在静态存储区申请了一块区域,用于存储转换后的字符串IP,那么在线程场景下这块区域就叫做临界区,多线程在不加锁的情况下同时访问临界区必然会出现异常情况。并且在APUE中,也明确提出inet_ntoa不是线程安全的函数。
下面我们在多线程场景下对inet_ntoa函数进行测试:
#include
#include
#include
#include
#include
void* Func1(void* arg)
{
struct sockaddr_in* p = (struct sockaddr_in*)arg;
while (1){
char* ptr1 = inet_ntoa(p->sin_addr);
std::cout << "ptr1: " << ptr1 << std::endl;
sleep(1);
}
}
void* Func2(void* arg)
{
struct sockaddr_in* p = (struct sockaddr_in*)arg;
while (1){
char* ptr2 = inet_ntoa(p->sin_addr);
std::cout << "ptr2: " << ptr2 << std::endl;
sleep(1);
}
}
int main()
{
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
pthread_t tid1 = 0;
pthread_create(&tid1, nullptr, Func1, &addr1);
pthread_t tid2 = 0;
pthread_create(&tid2, nullptr, Func2, &addr2);
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
但是实际在centos7上测试时,在多线程场景下调用inet_ntoa函数并没有出现问题,可能是该函数内部的实现加了互斥锁,这就跟接口本身的设计也是有关系的。
鉴于此,在多线程环境下更加推荐使用inet_ntop函数进行转换,因为该函数是由调用者自己提供缓冲区保存转换结果的,可以规避线程安全的问题。
资源未释放干净
当我们在测试网络代码时,先将服务端绑定8081端口运行,然后运行客户端,并让客户端连接当前服务器。
此时在有客户端连接服务端的情况下,如果直接将服务端关闭,此时服务端要想再次绑定8081号端口运行,就可能会绑定失败。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bRk4BU3X-1692352744261)(https://cdn.nlark.com/yuque/0/2023/png/29339358/1692351931558-c5ce13f6-fecc-48e4-aff4-c4f2ccf3a0d4.png#averageHue=%23080706&clientId=uea0765a4-4936-4&from=paste&height=438&id=u01866989&originHeight=548&originWidth=1874&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=64906&status=done&style=none&taskId=u24c504ee-f2c4-45a9-b88d-a5b15bdb08d&title=&width=1499.2)]
绑定失败这个问题涉及TCP通信中双方状态变化的一个细节,这里暂时无法解释清楚,后面博主在讲解TCP协议细节时会详谈。这里想说明的就是,绑定是有可能失败的,这里绑定失败实际是因为服务端退出时没有将资源释放干净。
端口号已被其他程序绑定
此外,绑定失败还有可能因为当前端口号已经被其他程序绑定了。比如一个程序已经绑定了8081号端口,此时另一个程序也想绑定8081号端口,此时该程序就会绑定失败。
这实际也就验证了一个端口号只能被一个进程所绑定这样的规则,此时也就确保了端口号到服务之间的映射本身就具备唯一性。
无法绑定的端口号
我们自己编写的服务器代码在绑定端口号时,尽量不要绑定1024以下的端口号。一般云服务器只能绑定1024及其往上的端口号,因为1024以下的端口已经约定俗成被其他一些比较成熟的服务所使用了,如果我们绑定1024以下的端口号,那么会绑定失败。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-erXD6VKL-1692352744261)(https://cdn.nlark.com/yuque/0/2023/png/29339358/1692352007316-9a15a2a3-2594-4796-8ea3-a2812260dbd3.png#averageHue=%230c0807&clientId=uea0765a4-4936-4&from=paste&height=272&id=ufd52a87b&originHeight=340&originWidth=1234&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=46342&status=done&style=none&taskId=u11a0297f-d020-4bd0-9138-23db737266a&title=&width=987.2)]
因此我们一般只能绑定1024及其往上的端口号,最好绑定8000及其网上的端口号。
说明一下:
下图是基于TCP协议的客户端/服务器程序的一般流程:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fM1P6Xt6-1692352744262)(https://cdn.nlark.com/yuque/0/2023/png/29339358/1692352070213-05bce34a-32d6-4bb9-8d51-7012ccff5ae4.png#averageHue=%23f9f9f8&clientId=uea0765a4-4936-4&from=paste&height=509&id=ubcd2c405&originHeight=636&originWidth=883&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=107162&status=done&style=none&taskId=u2272df35-9b07-40c2-8cf7-6d03ca822b9&title=&width=706.4)]
下面我们结合TCP协议的通信流程,来初步认识一下三次握手和四次挥手,以及建立连接和断开连接与各个网络接口之间的对应关系。
4&from=paste&height=501&id=u66b329e5&originHeight=626&originWidth=1820&originalType=binary&ratio=1.25&rotation=0&showTitle=false&size=111234&status=done&style=none&taskId=u5c7743ba-2df0-473f-9bc7-3b550a562d1&title=&width=1456)]
初始化服务器
当服务器完成套接字创建、绑定以及监听的初始化动作之后,就可以调用accept函数阻塞等待客户端发起请求连接了。
服务器初始化:
建立连接
而客户端在完成套接字创建后,就会在合适的时候通过connect函数向服务器发起连接请求,而客户端在connect的时候本质是通过某种方式向服务器三次握手,因此connect的作用实际就是触发三次握手
建立连接的过程:
这个建立连接的过程,通常称为三次握手。
需要注意的是,连接并不是立马建立成功的,由于TCP属于传输层协议,因此在建立连接时双方的操作系统会自主进行三次协商,最后连接才会建立成功。
数据交互
连接一旦建立成功并且被accept获取上来后,此时客户端和服务器就可以进行数据交互了。需要注意的是,连接建立和连接被拿到用户层是两码事,accept函数实际不参与三次握手这个过程,因为三次握手本身就是底层TCP所做的工作。accept要做的只是将底层已经建立好的连接拿到用户层,如果底层没有建立好的连接,那么accept函数就会阻塞住直到有建立好的连接。
而双方在进行数据交互时使用的实际就是read和write,其中write就叫做写数据,read就叫做读数据。write的任务就是把用户数据拷贝到操作系统,而拷贝过去的数据何时发以及发多少,就是由TCP决定的。而read的任务就是把数据从内核读到用户。
数据传输的过程:
端口连接
当双方通信结束之后,需要通过四次挥手的方案使双方断开连接,当客户端调用close关闭连接后,服务器最终也会关闭对应的连接。而其中一次close就对应两次挥手,因此一对close最终对应的就是四次挥手。
断开连接的过程:
这个断开连接的过程,通常称为四次挥手。
注意通讯流程与socket API之间的对应关系
在学习socket API时要注意应用程序和TCP协议是如何交互的:
为什么要断开连接?
建立连接本质上是为了保证通信双方都有专属的连接,这样我们就可以加入很多的传输策略,从而保证数据传输的可靠性。但如果双方通信结束后不断开对应的连接,那么系统的资源就会越来越少。
因为服务器是会收到大量连接的,操作系统必须要对这些连接进行管理,在管理连接时我们需要“先描述再组织”。因此当一个连接建立后,在服务端就会为该连接维护对应的数据结构,并且会将这些连接的数据结构组织起来,此时操作系统对连接的管理就变成了对链表的增删查改。
如果一个连接建立后不断开,那么操作系统就需要一直为其维护对应的数据结构,而维护这个数据结构是需要花费时间和空间的,因此当双方通信结束后就应该将这个连接断开,避免系统资源的浪费,这其实就是TCP比UDP更复杂的原因之一,因为TCP需要对连接进行管理。