共享内存是在进程之间交换数据的最快方法。但是,与流机制(管道,所有条带的套接字,文件队列等)不同,这里的程序员拥有完全的操作自由,因此,他们可以编写所需的内容。
因此,作者曾经想知道如果...在不同进程中共享内存段的地址是否退化。这实际上是在共享内存进程派生时发生的事情,但是不同的进程又如何呢?同样,并非所有系统都具有叉子。
地址似乎是重合的,那又如何呢?至少,您可以使用绝对指针,这样可以省去很多麻烦。使用由共享内存构造的C ++字符串和容器将成为可能。
顺便说一句,就是一个很好的例子。并不是说作者真的喜欢STL,但这是一个展示所提出的技术性能的紧凑且易于理解的测试的机会。允许(看起来)显着简化并加速进程间通信的技术。无论它是否有效以及您如何付款,我们都会进一步了解。
介绍
共享内存的想法简单而优雅-因为每个进程都在其自己的虚拟地址空间中运行,该虚拟地址空间被投影到系统范围的物理内存中,所以为什么不让来自不同进程的两个段查看同一物理内存区域。
随着64位操作系统的激增以及相干缓存的普遍使用,共享内存的想法风靡一时。现在,它不仅是一个循环缓冲区(一种“管道”的DIY实现),而且是一个真正的“连续变函数器”,这是一个极其神秘和强大的设备,而且,只有其神秘性才等于其功能。
让我们看一些使用示例。
- “shared memory” MS SQL. (~10...15%)
- Mysql Windows “shared memory”, .
- Sqlite WAL-. , . (chroot).
- PostgreSQL fork - . , .
.1 PostgreSQL ()
一般来说,我们希望看到理想的共享内存吗?这是一个简单的答案-我们希望可以使用其中的对象,就像它们是同一进程的线程之间共享的对象一样。是的,您需要同步(并且仍然需要同步),否则,您就可以使用并使用它!也许...可以安排。
概念验证需要最低限度的有意义的任务:
- 在共享内存中有一个std :: map <std :: string,std :: string>的类似物
- 我们有N个进程以与进程号相对应的前缀异步添加/更改值(例如:进程1的key_1_ ...)
- 结果,我们可以控制最终结果
让我们从最简单的事情开始-因为我们有std :: string和std :: map,所以我们需要一个特殊的STL分配器。
分配器STL
假设有xalloc / xfree函数用于与共享内存类似的malloc / free。在这种情况下,分配器如下所示:
template <typename T>
class stl_buddy_alloc
{
public:
typedef T value_type;
typedef value_type* pointer;
typedef value_type& reference;
typedef const value_type* const_pointer;
typedef const value_type& const_reference;
typedef ptrdiff_t difference_type;
typedef size_t size_type;
public:
stl_buddy_alloc() throw()
{ // construct default allocator (do nothing)
}
stl_buddy_alloc(const stl_buddy_alloc<T> &) throw()
{ // construct by copying (do nothing)
}
template<class _Other>
stl_buddy_alloc(const stl_buddy_alloc<_Other> &) throw()
{ // construct from a related allocator (do nothing)
}
void deallocate(pointer _Ptr, size_type)
{ // deallocate object at _Ptr, ignore size
xfree(_Ptr);
}
pointer allocate(size_type _Count)
{ // allocate array of _Count elements
return (pointer)xalloc(sizeof(T) * _Count);
}
pointer allocate(size_type _Count, const void *)
{ // allocate array of _Count elements, ignore hint
return (allocate(_Count));
}
};
这足以钩住std :: map和std :: string了
template <typename _Kty, typename _Ty>
class q_map :
public std::map<
_Kty,
_Ty,
std::less<_Kty>,
stl_buddy_alloc<std::pair<const _Kty, _Ty> >
>
{ };
typedef std::basic_string<
char,
std::char_traits<char>,
stl_buddy_alloc<char> > q_string
在处理声明的xalloc / xfree函数(它们与共享内存之上的分配器一起使用)之前,值得了解共享内存本身。
共享内存
同一进程的不同线程在同一地址空间中,这意味着任何线程中的每个non- thread_local指针都位于同一位置。使用共享内存,需要花费更多的精力来实现此效果。
视窗
- 让我们创建一个文件到内存的映射。分页机制涵盖了与普通内存一样的共享内存,在这里,除其他事项外,确定我们将使用共享分页还是为此分配特殊文件。
HANDLE hMapFile = CreateFileMapping( INVALID_HANDLE_VALUE, // use paging file NULL, // default security PAGE_READWRITE, // read/write access (alloc_size >> 32) // maximum object size (high-order DWORD) (alloc_size & 0xffffffff),// maximum object size (low-order DWORD) "Local\\SomeData"); // name of mapping object
文件名前缀“ Local \\”表示将在会话的本地名称空间中创建对象。 - 要加入另一个进程已经创建的映射,请使用
HANDLE hMapFile = OpenFileMapping( FILE_MAP_ALL_ACCESS, // read/write access FALSE, // do not inherit the name "Local\\SomeData"); // name of mapping object
- 现在,您需要创建一个指向完成的显示的细分
void *hint = (void *)0x200000000000ll; unsigned char *shared_ptr = (unsigned char*)MapViewOfFileEx( hMapFile, // handle to map object FILE_MAP_ALL_ACCESS, // read/write permission 0, // offset in map object (high-order DWORD) 0, // offset in map object (low-order DWORD) 0, // segment size, hint); //
段大小0表示将使用考虑到偏移量的创建显示的大小。
这里最重要的是提示。如果未指定(NULL),则系统将自行选择地址。但是,如果该值不为零,则将尝试使用所需的地址创建所需大小的段。通过在不同进程中将其值定义为相同,可以实现共享内存地址的退化。在32位模式下,查找地址空间中未分配的大块连续块并不容易,在64位模式下则没有这种问题,您总可以找到合适的方法。
的Linux
这里的一切基本相同。
- 创建一个共享内存对象
int fd = shm_open( “/SomeData”, // , / O_CREAT | O_EXCL | O_RDWR, // flags, open S_IRUSR | S_IWUSR); // mode, open ftruncate(fd, alloc_size);
ftruncate . shm_open /dev/shm/. shmget\shmat SysV, ftok (inode ). -
int fd = shm_open(“/SomeData”, O_RDWR, 0);
-
void *hint = (void *)0x200000000000ll; unsigned char *shared_ptr = (unsigned char*) = mmap( hint, // alloc_size, // segment size, PROT_READ | PROT_WRITE, // protection flags MAP_SHARED, // sharing flags fd, // handle to map object 0); // offset
hint.
关于提示,对其值有什么限制?实际上,存在各种限制。
首先,架构/硬件。关于虚拟地址如何变成物理地址,这里应该说几句话。如果存在TLB高速缓存未命中,则必须访问称为页表的树结构。例如,在IA-32中,它看起来像这样:
图2 4K页的情况,在这里取
到树的入口是寄存器CR3的内容,不同级别的页面中的索引是虚拟地址的片段。在这种情况下,32位变为32位,一切都是公平的。
在AMD64中,图片看起来有些不同。
图3从此处获取的
AMD64、4K页面CR3现在具有40个有效位,而不是以前的20个有效位,在4个级别的页面树中,物理地址限制为52位,而虚拟地址限制为48位。
并且(在开始时)Ice Lake微体系结构(英特尔)只允许在使用5级页表时使用虚拟地址的57位(仍然是52位)。
到目前为止,我们仅讨论了英特尔/ AMD。仅作更改,在Aarch64体系结构中,页表可以为3或4级,从而允许分别在虚拟地址中使用39或48位(1)。
其次,软件限制。微软尤其基于市场考虑因素,在不同的操作系统选项上强加了这些(最多44位,最高8.1位/ Server12,最低48位)。
顺便说一句,48位数字,每个为6GB乘以4GB,也许在这样的开放空间中总有一个角落可以贴上提示。
共享内存分配器
首先。分配器必须存在于分配的共享内存中,并将其所有内部数据放在此处。
其次。我们正在谈论一种进程间通信工具,与TLS使用相关的任何优化都是无关紧要的。
第三。由于涉及多个进程,因此分配器本身可以生存很长时间,因此减少外部内存碎片尤为重要。
第四。不允许调用操作系统获取更多内存。因此,例如,dlmalloc直接通过mmap分配相对较大的块。是的,可以通过提高阈值来断奶,但是仍然可以。
第五。标准的进程内同步工具不适合使用,要么是全局的,并带有相应的开销,要么是直接位于共享内存中的东西(例如自旋锁)。比方说,感谢相关的缓存。在posix中,这种情况下也有未命名的共享信号量。
总的来说,考虑到上述所有因素,并且还因为有一个通过双胞胎手法分配的实时分配器(由Alexander Artyushin提供,略有修改),因此选择起来很容易。
让我们保留对实现细节的描述,直到更好的时候为止,现在公共接口很有趣:
class BuddyAllocator {
public:
BuddyAllocator(uint64_t maxCapacity, u_char * buf, uint64_t bufsize);
~BuddyAllocator(){};
void *allocBlock(uint64_t nbytes);
void freeBlock(void *ptr);
...
};
析构函数之所以琐碎是因为 BuddyAllocator不会获取任何无关的资源。
最后准备
由于所有内容都位于共享内存中,因此该内存必须具有标头。对于我们的测试,此标头如下所示:
struct glob_header_t {
// magic
uint64_t magic_;
// hint
const void *own_addr_;
//
BuddyAllocator alloc_;
//
std::atomic_flag lock_;
//
q_map<q_string, q_string> q_map_;
static const size_t alloc_shift = 0x01000000;
static const size_t balloc_size = 0x10000000;
static const size_t alloc_size = balloc_size + alloc_shift;
static glob_header_t *pglob_;
};
static_assert (
sizeof(glob_header_t) < glob_header_t::alloc_shift,
"glob_header_t size mismatch");
glob_header_t *glob_header_t::pglob_ = NULL;
- own_addr_是在创建共享内存时写入的,因此按名称加入共享内存的每个人都可以找到实际地址(提示)并在必要时重新连接
- 像这样对尺寸进行硬编码是不好的,但是可以接受测试
- 构造函数应由创建共享内存的进程调用,如下所示:
glob_header_t::pglob_ = (glob_header_t *)shared_ptr; new (&glob_header_t::pglob_->alloc_) qz::BuddyAllocator( // glob_header_t::balloc_size, // shared_ptr + glob_header_t::alloc_shift, // glob_header_t::alloc_size - glob_header_t::alloc_shift; new (&glob_header_t::pglob_->q_map_) q_map<q_string, q_string>(); glob_header_t::pglob_->lock_.clear();
- 连接到共享内存的过程已准备就绪
- 现在,我们有了测试所需的一切,除了xalloc / xfree函数
void *xalloc(size_t size) { return glob_header_t::pglob_->alloc_.allocBlock(size); } void xfree(void* ptr) { glob_header_t::pglob_->alloc_.freeBlock(ptr); }
看来我们可以开始了。
实验
测试本身非常简单:
for (int i = 0; i < 100000000; i++)
{
char buf1[64];
sprintf(buf1, "key_%d_%d", curid, (i % 100) + 1);
char buf2[64];
sprintf(buf2, "val_%d", i + 1);
LOCK();
qmap.erase(buf1); //
qmap[buf1] = buf2;
UNLOCK();
}
Curid是进程/线程号,创建共享内存的进程的curid为零,但是对于测试来说无关紧要。
Qmap,LOCK / UNLOCK对于不同的测试是不同的。
让我们做一些测试
- THR_MTX-多线程应用程序,同步通过std :: recursive_mutex,
qmap-全局std :: map <std ::字符串,std ::字符串> - THR_SPN是一个多线程应用程序,同步通过自旋锁进行:
std::atomic_flag slock; .. while (slock.test_and_set(std::memory_order_acquire)); // acquire lock … slock.clear(std::memory_order_release); // release lock
qmap-全局std :: map <std :: string,std :: string> - PRC_SPN-几个正在运行的进程,同步通过自旋锁:
qmap - glob_header_t :: pglob _-> q_map_while (glob_header_t::pglob_->lock_.test_and_set( // acquire lock std::memory_order_acquire)); … glob_header_t::pglob_->lock_.clear(std::memory_order_release); // release lock
- PRC_MTX-几个正在运行的进程,同步通过一个命名的互斥锁。
qmap - glob_header_t :: pglob _-> q_map_
结果(测试类型与进程/线程数):
1个 | 2 | 4 | 8 | 十六 | |
---|---|---|---|---|---|
THR_MTX | 1'56'' | 5'41'' | 7'53'' | 51'38'' | 185'49 |
THR_SPN | 1'26'' | 7'38'' | 25'30'' | 103'29'' | 347'04'' |
PRC_SPN | 1'24'' | 7'27'' | 24'02'' | 92'34'' | 322'41'' |
PRC_MTX | 4'55'' | 13'01'' | 78'14'' | 133'25'' | 357'21'' |
该实验是在具有Xeon®Gold 5118 2.3GHz,Windows Server 2016的双处理器(48核)计算机上进行的。
总
- 是的,只要设计合理,就可以使用来自不同进程的STL对象/容器(分配在共享内存中)。
- , , PRC_SPN THR_SPN. , BuddyAllocator malloc\free MS ( ).
- . — + std::mutex . lock-free , .
共享内存通常被用来传输大型数据流,这是一种手工制作的“管道”。即使您需要安排进程之间的昂贵同步,这也是一个好主意。我们看到它在PRC_MTX测试中并不便宜,即使在没有竞争的情况下,在一个流程中工作也会显着降低性能。
高成本的解释很简单,如果std ::(recursive_)互斥锁(Windows下的关键部分)可以像自旋锁一样工作,则命名的互斥锁是系统调用,并以相应的成本进入内核模式。而且,线程/进程失去执行上下文总是非常昂贵的。
但是由于流程的同步是不可避免的,我们如何降低成本?答案早就被发明了-缓冲。并非每个数据包都同步,而是同步了一定数量的数据-将该数据序列化到的缓冲区。如果缓冲区明显大于数据包大小,则必须减少同步的频率。
混合使用两种技术很方便-共享内存中的数据,并且仅相对指针(从共享内存的开头)通过进程间数据通道发送(例如:通过本地主机循环)。因为 指针通常小于数据包,可以节省同步时间。
而且,如果不同的进程可以访问同一虚拟地址的共享内存,则可以提高性能。
- 不要序列化要发送的数据,不要在接收时反序列化
- 通过流将诚实的指针发送到共享内存中创建的对象
- 当我们得到一个就绪的(指针)对象时,我们将其使用,然后使用常规删除将其删除,所有内存将自动释放。这使我们免于使用环形缓冲区。
- 您甚至可以不发送指针,而是(最小可能-值“您有邮件”的字节)发送通知,通知您队列中有内容
最后
对于在共享内存中构造的对象,该做或不做。
- 使用RTTI。出于明显的原因。std :: type_info对象存在于共享内存之外,并且在各个进程之间不可用。
- 使用虚拟方法。为了同样的原因。虚拟功能表和功能本身在整个过程中不可用。
- 如果我们谈论STL,则共享内存的进程的所有可执行文件都必须由具有相同设置的同一编译器进行编译,并且STL本身必须相同。
PS:感谢Alexander Artyushin和Dmitry Iptyshev(德米特里亚),以帮助您准备本文。