13

I've been doing some research on STM (software transactional memory) implementations, specifically on algorithms that utilize locks and are not dependent on the presence of a garbage collector in order to maintain compatibility with non-managed languages like C/C++. I've read the STM chapter in Herlihy and Shavit's "The Art of Multiprocessor Programming", as well as read a couple of Shavit's papers that describe his "Transactional Locking" and "Transactional Locking II" STM implementations. Their basic approach is to utilize a hash-table that stores the values of a global version-clock and a lock to determine if a memory location has been touched by another thread's write. As I understand the algorithm, when a writing transaction is performed, the version-clock is read and stored in thread-local memory, and a read-set and write-set are also created in thread-local memory. Then the following steps are performed:

  1. The values of any addresses read are stored in the read-set. This means that the transaction checks that any locations being read are not locked, and they are equal to or less than the locally stored version clock value.
  2. The values of any addresses written are stored in the write-set, along with the values that are to be written to those locations.
  3. Once the entire write-transaction is complete (and this can include reading and writing to a number of locations), the transaction attempts to lock each address that is to be written to using the lock in the hash-table that is hashed against the address' value.
  4. When all the write-set addresses are locked, the global version clock is atomically incremented and the new incremented value is locally stored.
  5. The write-transaction checks again to make sure that the values in the read-set have not been updated with a new version-number or are not locked by another thread.
  6. The write-transaction updates the version-stamp for that memory location with the new value it stored from step #4, and commits the values in the write-set to memory
  7. The locks on the memory locations are released

If any of the above check-steps fail (i.e., steps #1, #3, and #5), then the write-transaction is aborted.

The process for a read-transaction is a lot simpler. According to Shavit's papers, we simply

  1. Read and locally store the global version-clock value
  2. Check to make sure the memory locations do not have a clock value greater than the currently stored global version-clock value and also make sure the memory locations are not currently locked
  3. Perform the read operations
  4. Repeat step #2 for validation

If either step #2 or #4 fail, then the read-transaction is aborted.

The question that I can't seem to solve in my mind though is what happens when you attempt to read a memory location inside an object that is located in the heap, and another thread calls delete on a pointer to that object? In Shavit's papers, they go into detail to explain how there can be no writes to a memory location that has been recycled or freed, but it seems that inside of a read-transaction, there is nothing preventing a possible timing scenario that would allow you to read from a memory location inside of an object that is has been freed by another thread. As an example, consider the following code:

Thread A executes the following inside of an atomic read-transaction: linked_list_node* next_node = node->next;

Thread B executes the following: delete node;

Since next_node is a thread-local variable, it's not a transactional object. The dereferencing operation required to assign it the value of node->next though actually requires two separate reads. In between those reads, delete could be called on node, so that the read from the member next is actually reading from a segment of memory that has already been freed. Since the reads are optimistic, the freeing of the memory pointed to by node in Thread B won't be detected in Thread A. Won't that cause a possible crash or segmentation fault? If it does, how could that be avoided without locking the memory locations for a read as well (something that both the text-book as well as the papers denotes is unnecessary)?

Jason
  • 31,834
  • 7
  • 59
  • 78
  • Indeed this is weird, it would be great if you could grab @jalf's attention, his Master Thesis was on STM so hopefully he'd be able to either explain why it works or confirm that you're right. He maintains a blog at: http://jalf.dk/blog/ and could perhaps be found in the C++ Lounge. – Matthieu M. Dec 08 '11 at 07:46

1 Answers1

5

The simple answer is that delete is a side effect, and transactions do not play nice with side effects.

Logically, because transactions can be rolled back at any time, you can't deallocate memory in the middle of a transaction.

I don't think there is a single universal answer to "how this shall be handled", but a common approach is to defer the delete call until commit-time. The STM API should either do this automatically (for example providing their own delete function and requiring you to do that), or by giving you a hook where you can register "actions to perform on commit". Then during your transaction you can register an object to be deleted if and when the transaction commits.

Any other transaction working on the deleted object should then fail the version check and roll back.

Hope that helps. There isn't a simple answer to side effects in general. It's something each individual implementation will have to come up with mechanisms to handle.

jalf
  • 243,077
  • 51
  • 345
  • 550
  • Won't performing memory recycling during the commit process still leave an inconsistent state for optimistic readers? Unless a read transaction re-reads the original pointer to the memory location that was deleted to see it was changed, won't it continue to access memory that has been reclaimed by another write-transaction? Would a possible solution be the use of separate read-locks along with your special `STM-delete` method? The read-locks would only be used during deletion to verify no-transaction is referencing the memory location, adding no overhead in write-transactions. – Jason Dec 08 '11 at 13:32
  • Yep, you're right. I don't use optimistic readers in my implementation for that reason. It could work in a managed language like Java or C# (because the error could then be caught and the read transaction rolled back), but in C/C++, it'd throw you straight into undefined behavior. In my implementation, I use a kind of light-weight tracking of readers so the object is never modified in-place while readers are accessing it – jalf Dec 08 '11 at 16:26
  • In short, I use a kind of double-buffering. Each transactional object actually stores two copies of itself, a kind of "front" and "back" buffer (to borrow the terminology from graphics). Readers always access the front buffer, and commits are written to the back buffer. Following a commit, the buffers are flipped. In addition, I track the number of readers that have accessed each buffer, and so I forbid the buffer from being flipped if the number of readers on the back buffer is not zero – jalf Dec 08 '11 at 16:28
  • In practice that avoids the problems with optimistic readers (because objects are guaranteed to stay unchanged as long as *anyone* is using them), while minimizing the performance hit it causes (I can still commit even though transactions are reading the front buffer, I just can't commit *twice*, until the readers that currently exist have released the object) – jalf Dec 08 '11 at 16:30
  • But yeah, schemes based on optimistic readers in C or C++ are basically broken. There's just no way to safely recover once the readers are thrown off by some transaction modifying the data they're using. – jalf Dec 08 '11 at 16:32
  • Thanks for taking the time to answer my question ... this was very enlightening as a lot of the research papers out there seem to gloss over these issues with optimistic readers, and I was wondering if there was something obvious I was missing that everyone else in the research community understood as some basic fact. BTW, I'd be interested in reading your thesis at some point. The link on your blog still gives me a 404 error, but no rush :-) – Jason Dec 08 '11 at 16:52
  • Well, there might be some secret trick that makes it all work with optimistic readers, but I'm not aware of it. I think the reason it's glossed over is that (1) a lot of researchers are working with Java, where it's possible to handle safely, and (2) those who work in C haven't really bothered too much with the nitty-gritty details. – jalf Dec 08 '11 at 18:17
  • In practice you can *often* get it if you handle segfaults *and* you verify the read transaction when committing, *and* the data you're reading from doesn't change arbitrarily (if the deleted memory still stores its former value, your tx is unlikely to fail irrecoverably. And I'll fix the link as soon as I have 5 minutes. Thanks for pointing it out :) – jalf Dec 08 '11 at 18:17