您的位置:首页 > 运维架构 > Linux

key-value 多线程服务器的Linux C++实现

2015-07-17 15:09 423 查看
项目需求

总体思路

网络通信

字符解析

数据存储与查询
1 存储管理

2 数据查询

多线程

待改进未实现的想法

GitHub源码

项目需求

设计一个基于Socket或基于HTTP的服务器,服务内容是提供一种简单的key/value映射关系的管 理与查询

下面的所有操作都是通过结构体Node来传递的:

struct Node {

char key[KEY_SIZE];

char value[VALUE_SIZE];

};

本场景中需要client和server两个程序

client端只有两种操作:

int AddNode(const struct Node *node); // 将指定的node保存到server上,需要key和value都 完整

int GetNode(struct Node *node); // 输入的node需要有完整的key,server负责将这个key对应 的value填到node中,或返回不存在

server: server端就是接收client的两种请求,然后要么保存node要么查询node并返回值。

server端怎么保存node不作要求,但要达到以下的几点:

1. 如果将所有node都保存在内存中,那么当内存中的数据太多时,需要定期将node保存成磁 盘文件。

2. client端在执行AddCell时,如果对应的key已经存在于server端,那么覆盖原有的值。

1. 总体思路

首先我们要开发一个基于网络的服务器,那么网络的知识是必须的,我们要开发的服务器是一种要求端对端的可靠的服务器,因此将采用基于TCP协议和套接字Socket作为服务端和客户端之间的通信方式。进,而我们可以观察到我们操作的数据是一个(key,value)形式,而TCP是一个以字节流传输的连接,因此需要一个字符串(char*)和数据结构(key,value)之间的相互转换函数,来将服务端和客户端之间的传输字节流解析成命令和数据节点(以及将命令和数据节点打包成字符串进行通信)。然后我们必须考虑服务器上数据的存储和查询问题,这是项目实现的关键。最后,服务器肯定要支持多客户端的同时连接和通信,因此还必须添加多线程或多进程。

本项目已实现的功能

基于Socket的TCP网络传输

对网络传输字符的解析与打包

LRU Page 缓存

Hash-map 查找

多线程多clients 同时put,get操作

以下将从四个方面进行阐述项目思想:网络通信,字符解析,数据存储与查询,多线程实现。

2. 网络通信

基于本服务器的功能特性考虑,我们要求的是可靠性,因此选择TCP。

而在TCP的连接前,

- 服务器首先要做的准备是:创建一个连接套接字socket,然后socket绑定(bind)在一个IP和Port上以供客户端寻找,监听(listen)客户端的请求,然后accept一直阻塞等待客户端的连接。

- 客户端需要做的准备:客户端的套接字附上服务端的ip地址和自己的端口号。

当服务端和客户端做好通信准备后,由客户端发起,服务端响应,经过三次握手,建立相互之间的连接。

当一个客户端通信完成后, 客户端会主动向服务端请求释放连接。

关于三次握手以及当一个客户端通信完成,与服务端释放连接时的四次挥手的详细知识,请参考wireshark抓包图解 TCP三次握手/四次挥手详解

建立连接后,服务端调用accept()函数,阻塞服务端进程,直至收到客户端send()过来的信息。

关于网络编程中的Socket接口函数的详细讲解,参考Linux的SOCKET编程详解

3. 字符解析

由于网络通信中传输的是字符,我们必须将字符解析成 命令(put,get,…etc.)和数据(key,value)。

由上述得知,传输字符包含有:命令,key,value;我们可根据习惯,设立分割字符,如空格,分号等。需要注意的是每次传输的字符串分割后,得到的子字符串的个数可能是不同的,如”put 12:quinn” 分为3段,而”get 12”分为2段,”exit”只有一段。因此根据客户端发信给客户端的命令不同,字符解析函数将它解析成不同的命令。而服务端首先判断第一段字符的含义(put,get,exit,save等),来决定自己要实现的动作。

源码查看:https://github.com/qzxin/key-value-server/blob/master/convert.cpp

4. 数据存储与查询

4.1 存储管理

由于内存空间有限,当数据量到达上限时,必须把数据转存到磁盘文件中。

在服务器实现过程中,服务端需要接受客户端的get和put两种操作,

- put(key, value): 在接收一定数量的数据后需要将数据保存到磁盘上,并且需要检查是否存在相同的key;

- get(key): 向服务端查询是否存在该key;

因为项目需求相同的key只能有一个值,所以不管是put和get都必须遍历内存和磁盘文件中的数据,查找是否含有该key,如果每一次操作都需要访问磁盘,那么效率将是极低的,由于访问数据的时间局部性,最近访问过的数据在近期内有更大的可能再次被访问,因此想到了引入内存缓存系统,即将最近访问过的节点保留在内存中;又由访问数据的空间局部性,最近访问过的数据周围的数据有更大的可能被访问,想到了引入分页机制。

缓存系统:当查找节点时,首先在缓存中查找,查找成功则对该节点操作。缓存查找失败,即缺页中断。因为内存空间限制,缓存的大小是一定的,当发生缺页中断时,要从磁盘中加载新数据,即需要不断的用最近访问成功的新数据替换缓存中的旧数据。

分页机制:当待查找数据不在缓存,即缺页中断时,如果每次都从磁盘中加载一个数据,那效率是不可接受的。因此,将页(包含N个数据节点)作为缓存和磁盘数据之间操作的基本单位。

如上提到的缺页中断是操作系统中内存管理中的概念。

在请求分页存储管理系统中,由于使用了虚拟存储管理技术,使得所有的进程页面不是一次性地全部调入内存,而是部分页面装入。

这就有可能出现下面的情况:要访问的页面不在内存,这时系统产生缺页中断。操作系统在处理缺页中断时,要把所需页面从外存调入到内存中。如果这时内存中有空闲块,就可以直接调入该页面;如果这时内存中没有空闲块,就必须先淘汰一个已经在内存中的页面,腾出空间,再把所需的页面装入,即进行页面置换。

当缺页中断时,需要进行页面置换。而常见的页面置换算法有:FIFO,LRU和时钟算法。

(1)FIFO是淘汰内存中存在时间最长的页,而最长的页可能是最常被访问的,因此性能差。

(2)LRU是淘汰内存中最久没有被访问的页。

(3)时钟算法是,将页连成一个环形链表,当缺页中断时,指针指向最老的页,当该页的访问位为0,则删除该页,若该页访问位为1,则将访问位置0,遍历它的下一页,直至遇到一个访问位为0的页,用新数据替换它,并把指针指向它的下一页。

注意,本文中假设”数据缓存“存在于内存中,即内存缓存,而”磁盘中的数据文件“模拟现代OS中的虚拟内存。即本文将缓存放在内存,将磁盘文件当做缓存页。

本文选用容易实现且性能尚可的LRU页面置换。具体实现过程已在另一篇博文基于文件页的 LRU Cache:磁盘缓存实现中详细描述,本文不再赘述。

本文思想是,为了更便利的对页数据进行置换,将磁盘文件的大小设置为页的大小,形成映射。当缺页中断时,调入新的一页时,即读一个新文件到内存中,而如何定位文件,下文分析;而被替换掉的页,如果页的dirty位为1,则重新写入到它所属的文件,为了实现这一点,在页的数据结构中应该包含该页所属文件的编号。(这是OS中虚拟缓存的思想

如何定位key所在的文件?(2015/07/19更新)

建立(key, file)映射的hash表。put操作时,将每一个新key和该key将要存入的文件序号压入一个hash-map;get操作或put操作,search(key)时,如果该key不在缓存中,那么查key-file映射表,如果存在该key对应的文件,则将该文件加载进缓存中,否则返回不存在该key。注意,在服务器启动时,应该加载(key,file)的映射表(它们存储在一个文件中)。

class HashCache::Page {
public:
int file_num_; // 页所对应的文件序号
bool lock_;
bool dirty_;  // 标记page是否被修改
class Node data_[PAGE_SIZE];  // 页包含的节点数据
class Page* next;
class Page* prev;
Page() {
lock_ = false;
dirty_ = false;
}
};


4.2 数据查询

4.1存储管理 解决了数据的存储和加载问题,那么如何能快速的索引到一个数据呢?两种办法,平衡二叉树O(lgn)和hash表O(1);我们知道hash表的缺点是不能有效解决冲突,而本项目中的key,value唯一,因此采用更快的hash-map实现数据的索引,当然采用时间复杂度为O(lgn)的map实现也是可以的。

在实际操作中,当每次缺页中断,加载一页时,将新页的数据都插入到hash-map中,同时将被替换页的数据从hash-map中释放。而为了保证这点,在构建数据结构时,每一个数据节点必须包含它所属的页号

class HashCache::Node {
public:
std::string key_;
std::string value_;
class Page* page_;  // 该数据节点所属页
};


总结:由操作系统中的内存页面置换和虚拟缓存中的理论,迁移得到本项目服务器数据的存储和查询的实现思想。

本节思想的具体实现步骤,已再另一篇博文中描述点击此处查看

5. 多线程

2015/09/30 更新

对于多线程实现,由于线程的创建和销毁耗费时间和资源,因此对于大量的短的传输任务可以用线程池的方式实现。

一个服务器肯定是要支持多客户端通信的,那么应该使用多进程还是多线程呢?

由上文可知,所有的数据都是先存储到内存中,然后再转存到文件中,那么为了内存数据(缓存)的共享,选用多线程实现。

每当有一个客户端和服务器连接成功后,新建一个线程,将连接套接字传入线程处理函数,然后分离(detach)该线程,由该线程处理该客户端的所有通信。

因为是通过”共享内存“的方式实现线程之间的通信,可能存在多个客户端同时针对一个key的value做修改,同时有客户端在读取该key的value,造成数据的不同步?那应该如何解决线程同步问题呢?

线程同步的方式:临界区,互斥锁,信号量,事件

本项目,采用互斥锁解决数据之间的同步问题,引入2个锁:写入锁和读取锁。当有一个正在put时,所有的put和get操作等待;当有get操作时,可以再有get操作,put操作等待。(读者写者问题的经典思想)

C++多线程编程,详情请参阅:C++11 编写 Linux 多线程程序

C++线程信号量和锁,详情请参阅:C++11 并发指南三(std::mutex 详解)

2015/09/30 更新

如上述所述,每一次操作缓存都要锁定整个缓存部分,可以做出如下改进:使用两个互斥量进行加锁,当读取或者写入一个页的数据时,对该页进行加锁,其他页可以正常访问;但是,当将刚刚操作的页放到双向链表的头部时,需要对整个链表(整个缓存)进行加锁。这样粒度更小,效率更高。

6. 待改进,未实现的想法

如何加入断电缓存重建机制?

如何加入查询超时判断?

需不需要线程调度?

能否把所有的key全放到一个set里,当cache中不存在该key时,去set里查找,如果存在然后才去遍历文件;不存在则直接返回。

是不是还可以,将key,page_num对存入一个hash-map,根据key直接索引到其所属的页(对应文件号)。

其他参考信息:淘宝自主开发的一个分布式key/value存储系统Tair,开发本项目时没有发现~~~

7. GitHub源码

本项目开发环境Linux GCC4.8.4 ,C++ 11

源码:https://github.com/qzxin/key-value-server

原文:key-value 多线程服务器的Linux C++实现/article/9789329.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: