COW (Copy On Write) in GCC

Article directory

In the GCC 5.1 release libstdc++ introduced a new library ABI that includes new implementations of std::string and std::list. These changes were necessary to conform to the 2011 C++ standard which forbids Copy-On-Write strings and requires lists to keep track of their size. - Dual ABI

Let's start with a few digressions. The COW in the old version of GCC confirms the point I mentioned in some ideas. The world in computer is realistic, pursuing the maximum benefit and full of deception. lazy's thoughts are flying all over the world. The most typical one is the virtual address space. Of course, there is no idea about the data in the computer, so it should be done. Of course, if it violates the semantics of C + +, I dare not say.

What is COW?

COW is copy on write. Although C + + has good performance. Programmers can manage memory manually and compile AOT, there are still some performance costs in meaningless copies (especially deep copies). For the copy cost of temporary objects, the compiler can avoid this part of the cost by optimizing NRVO and RVO. Later, C++11 adds the right value reference and move semantics, and gives the permission to the programmer, so that the programmer can actively expose more optimization opportunities. Similarly, std::string_view.

While COW is the compiler's optimization, similar to NRVO and RVO, but different from the latter two. The latter two belong to Copy Elision and directly omit the middle constructor. While COW is to modify the definition of std::string. The specific ideas of COW are as follows:

Basic idea: to share a data buffer among string instances, and only make a copy for a specific instance (the copy on write) when that instance's data is modified. - Why COW was deemed ungood for std::string

For example, in the following code, before the other is modified, str and other share a block of memory area. Such a lazy idea is very common in the computer world, and the fork in linux is similar.

// debian8
#include <string>
#include <iostream>

int main() {
	std::string str = "hello world";  // str owns the string 'hello world'
	std::string other = str; // no copy occurs, more like shallow copy
	std::cout << (void*) str.data() << std::endl;
	std::cout << (void*) other.data() << std::endl;
}

// give the result as follows
$0x1a4a028
$0x1a4a028
$0x2485058

When a fork() system call is issued, a copy of all the pages corresponding to the parent process is created, loaded into a separate memory location by the OS for the child process. But this is not needed in certain cases. Consider the case when a child executes an "exec" system call (which is used to execute any executable file from within a C program) or exits very soon after the fork(). When the child is needed just to execute a command for the parent process, there is no need for copying the parent process' pages, since exec replaces the address space of the process which invoked it with the command to be executed.
In such cases, a technique called copy-on-write (COW) is used. With this technique, when a fork occurs, the parent process's pages are not copied for the child process. Instead, the pages are shared between the child and the parent process. Whenever a process (parent or child) modifies a page, a separate copy of that particular page alone is made for that process (parent or child) which performed the modification. This process will then use the newly copied page rather than the shared one in all future references. The other process (the one which did not modify the shared page) continues to use the original copy of the page (which is now no longer shared). This technique is called copy-on-write since the page is copied when some process writes to it.

Copy Elision

Due to the popularity of compiler optimization technologies such as NRVO and RVO, C++11 uses copy elision to represent such technologies and incorporates them into the C + + standard. Interested parties can use the parameter fno elide constructors to see the difference between copy elision and No.

Under the following circumstances, the compilers are required to omit the copy and move construction of class objects, even if the copy/move constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would itherwise be copied/moved to. - Copy elision

Realization

Possible implementation

I wrote a rough implementation with lots of bug s.

class my_string {
	std::shared_ptr<std::vector<char>> ptr;
	bool owner;
public:
	my_string(const char* str) : ptr(std::make_shared<std::vector<char>>()), owner(true) {
		while(*str != '\0') {
			ptr->push_back(*str++);
		}
	}
	my_string(const my_string &rhs) {
		ptr = rhs.ptr;
		owner = false;
	}
	char& operator[](size_t i) {
		// Expose the internal address, we must be sure that current object owns the buffer.
		if (!owner) {
			ptr = std::make_shared<std::vector<char>>(*ptr);
		}
		return (*ptr)[i];
	}
	my_string& operator=(const char* str) {
		if (!owner) {
			ptr = std::make_shared<std::vector<char>>();
		}
		while(*str != '\0') {
			ptr->push_back(*str++);
		}
		owner = true;
		return *this;
	}
	char* data() {
		return ptr->data();
	}
};

among Why COW was deemed ungood for std::string Write a slightly less rough version, the implementation principle is the same.

The implementation of libstdc + +

In this paper, the implementation of libstdc + + corresponding to gcc-4.6.2 is used to illustrate how libstdc + + implements copy on write on std:string. The implementation principle is similar to the above implementation. It is based on a reference count to determine how many object share s the current buffer is, and then when encountering API calls that may modify std::string, it will actually copy. Specific codes can refer to basic_string.h.
Note: std::string is STD:: basic string < char >

What's the problem?

So what's the problem with COW? stackoverflow This example is really ingenious.

std::string s("str");
const char *p = s.data();
{
	std::string s2(s);
	(void) s[0]; // This line will unshares the buffer, so 
}
std::cout << *p << '\n';

Stack overflow gives a general explanation

What happens is that when s2 is constructed it shares the data with s, but obtaining a non-const reference via s[0] requires the data to be unshared, so s does a "copy on write" because the reference s[0] could potentially be used to write into s, then s2 goes out of scope, destroying the array pointed to by p.

The whole process is as follows:

Note: the above diagram is rough, the specific implementation code is complex, and there are some optimizations

There are some keywords you want to see in your code, 65123; m ﹣ mutate(), ﹣ m ﹣ dispose, ﹣ m ﹣ destroy. In fact, the main reason for pointer failure is the realization of COW, discussion on how to correct COW to avoid pointer failure The final answer is that the realization of pointer invalidation is the best, which will be added in the future.

What is the relationship between COW and C++11

So where does COW violate C++11? It mainly focuses on whether the operator [] and data() will make the pointer fail.

The C++03 standard explicitly permits that behaviour in 21.3 [lib.basic.string] p5 where it says that subsequent to a call to data() the first call to operator may invalidate pointers, references and iterators. - Legality of COW std::string implementation in C++11

In the example code, s.data() is called first, and then s.operator [], which is specified in C++03 that this will invalidate the pointer. But this rule doesn't exist in C++11.

The C++11 standard no longer permits that behaviour, because no call to operator may invalidate pointers, references or iterators, irrespective of whether they follow a call to data(). - Legality of COW std::string implementation in C++11

I went through the latest standards (February 1, 2020), and also mentioned that in any case, operator [] and data() should not invalidate reference, pointer or iterator.

Another important problem is that under multithread, the performance of COW is poor Concurrency Modifications to Basic String The implementation of direct dissallow copy on write is proposed.

How to trigger

I use vagrant to add two images: generic / Debian 9 and generic / Debian 8. The version of gcc in generic / Debian 9 is 6.3.0, and the version of gcc in generic / Debian 8 is 4.9.2. The former has no COW and the latter has COW.

First, use the following code, generic / Debian 8 and generic / Debian 9, to test the COW. You can see that the shallow copy is performed on Debian 8, and the deep copy is performed in the future when there is a write requirement.

#include <string>
#include <iostream>
int main() {
	std::string str = "hello world"; // str owns the string 'hello world'
	std::string other = str;
	std::cout << (void*) other.data() << std::endl;
	std::cout << (void*) str.data() << std::endl;
	other.append("!");
	std::cout << (void*) other.data() << std::endl;
}
// g++ -std=c++03 test.cpp
// debian8 results are as follows 
$ 0xd88028
$ 0xd88028
$ 0xc47058

// g++ -std=c++03 test.cpp
// debian9 results are as follows
$ 0x7ffe04a1ce80
$ 0x7ffe04a1ce60
$ 0x7ffe04a1ce60

Note: both are 64 bit. I don't know why the printed address format is different

But the core problem is how to reproduce the code in stackoverflow and judge that the pointer p is indeed invalid. Here is the test code.
Note: originally, I wanted to debug libstdc + +, but it seems to be more troublesome. For now, I use the following method

// debian8, gcc 
#include <string>
#include <iostream>
int main() {
        // std::string str = "hello world"; // str owns the string 'hello world'
        // std::string other = str;
        // std::cout << (void*) other.data() << std::endl;
        // std::cout << (void*) str.data() << std::endl;
        std::string str("str");
        const char* p = str.data();
        std::cout << "p pointer: " << (void*)p << std::endl;
        std::cout << (void*)(p - 4) << ": " << std::hex << (int)*(p - 4) << std::endl;
        std::cout << (void*)(p - 8) << ": " << std::hex << (int)*(p - 8) << std::endl;
        std::cout << (void*)(p - 12) << ": " << std::hex << (int)*(p - 12) << std::endl;
        {
                std::string other(str);
                std::cout << "other pointer: " << (void*)other.data() << ", str pointer; " << (void*)str.data() << std::endl;
                (void) str[0];
                std::cout << "other pointer: " << (void*)other.data() << ", str pointer; " << (void*)str.data() << std::endl;
        }
        std::cout << (void*)(p - 4) << ": " << std::hex << (int)*(p - 4) << std::endl;
        std::cout << (void*)(p - 8)<< std::hex << (int)*(p - 8) << std::endl;
        std::cout << (void*)(p - 12) << std::hex << (int)*(p - 12) << std::endl;
        std::cout << *p << std::endl;
}

// debian8 ouput
$ p pointer: 0xcfd028
$ 0xcfd024: 0
$ 0xcfd020: 0
$ 0xcfd01c: 0
$ 0xcfd018: 3
$ 0xcfd014: 0
$ 0xcfd010: 3
$ other pointer: 0xcfd028, str pointer; 0xcfd028
$ other pointer: 0xcfd028, str pointer; 0xcfd058
$ 0xcfd024: 0
$ 0xcfd020: ffffffff
$ 0xcfd01c: 0
$ 0xcfd018: 3
$ 0xcfd014: 0
$ 0xcfd010: 0
s

// debian8 output
$ p pointer: 0x7ffeab701aa0
$ 0x7ffeab701a9c: 0
$ 0x7ffeab701a98:3
$ 0x7ffeab701a94: fffffffe
$ 0x7ffeab701a90: ffffffa0
$ 0x7ffeab701a8c: 46
$ 0x7ffeab701a88ffffffbd
other pointer: 0x7ffeab701a80, str pointer; 0x7ffeab701aa0
other pointer: 0x7ffeab701a80, str pointer; 0x7ffeab701aa0
$ 0x7ffeab701a9c: 0
$ 0x7ffeab701a98:3
$ 0x7ffeab701a94: fffffffe
$ 0x7ffeab701a90: ffffffa0
$ 0x7ffeab701a8c: 46
$ 0x7ffeab701a88: ffffffbd
s

Now the core of the question is "how to judge whether a pointer is a dangling pointer?" , I didn't find a proper way to compare prices. Here, I use the memory allocator's booking information when allocating memory to determine whether the memory pointed by the pointer is free. So, we have to turn around to see how bookkeeping information is handled when libstdc + + allocates and destroys memory.

From the output of both, we can see that on Debian 8, although the printed content of * p is still s, the bookkeeping information or guard bytes in front of this memory has been invalidated. However, to find out the specific meaning, we need to find out how allocator handles guard bytes when allocating and freeing memory.

About the bookkeeping information in memory allocation, I blogged a few years ago new/delete and new[]/delete [] in C + + Related content has been introduced in.

Will the final memory allocation be implemented through malloc in glibc? maybe,

To be summarized

std::move and copy elision

RVO V.S. std::move

deduced return type

fork()

These 10 tricks that only library implementors know! - Jonathan Wakely & Marshall Clow

discussion on how to correct COW to avoid pointer failure

Concurrency Modifications to Basic String

paper Updated version

The implementation of malloc in glibc and libc

93 original articles published, 77 praised, 140000 visitors+
Private letter follow

Tags: glibc Linux less

Posted on Sat, 01 Feb 2020 08:50:28 -0500 by Clandestinex337