C++异常足以实现线程局部存储吗?

26
我正在评论回答,关于线程本地存储很好,并回想起另一个有启发性的讨论关于异常,我推测:

在 throw 块中,执行环境唯一特殊的事情是 rethrow 引用了异常对象。

综合考虑,将整个线程放在其主函数的函数 catch 块内部是否赋予它线程本地存储?

看起来可以正常工作,尽管速度较慢。这是新颖的还是已经被描述清楚了?还有其他解决问题的方法吗?我的最初假设是否正确? get_thread 在您的平台上会产生什么样的开销?有哪些优化的潜力?

#include <iostream>
#include <pthread.h>
using namespace std;

struct thlocal {
    string name;
    thlocal( string const &n ) : name(n) {}
};

struct thread_exception_base {
    thlocal &th;
    thread_exception_base( thlocal &in_th ) : th( in_th ) {}
    thread_exception_base( thread_exception_base const &in ) : th( in.th ) {}
};

thlocal &get_thread() throw() {
    try {
        throw;
    } catch( thread_exception_base &local ) {
        return local.th;
    }
}

void print_thread() {
    cerr << get_thread().name << endl;
}

void *kid( void *local_v ) try {
    thlocal &local = * static_cast< thlocal * >( local_v );
    throw thread_exception_base( local );
} catch( thread_exception_base & ) {
    print_thread();

    return NULL;
}

int main() {
    thlocal local( "main" );
    try {
        throw thread_exception_base( local );
    } catch( thread_exception_base & ) {
        print_thread();

        pthread_t th;
        thlocal kid_local( "kid" );
        pthread_create( &th, NULL, &kid, &kid_local );
        pthread_join( th, NULL );

        print_thread();
    }

    return 0;
}

这确实需要定义新的异常类,继承自thread_exception_base,并使用get_thread()初始化基类,但总体来说,这不像是一个无所事事的失眠星期天早上...

编辑:看起来GCC在get_thread中调用了三次pthread_getspecific编辑:还有一些对堆栈、环境和可执行文件格式的恶意内省,以找到我在第一次检查中错过的catch块。这看起来高度依赖平台,因为GCC正在从操作系统调用一些libunwind。开销约为4000个周期。我想它也必须遍历类层次结构,但可以控制。


主函数可以使用try/catch块吗? - anon
1
当然可以 - 标准规定main的catch块不处理全局/静态构造函数的抛出。这对于该机制并非必要。 - Potatoswatter
如果抛出第二个异常会发生什么? - user180326
@jdv:每个异常类都需要派生自“thlocal”,并且该基类始终需要使用“get_thread()”进行初始化。嗯,听起来像我需要在其中加入一个中间指针以避免复制数据。 - Potatoswatter
2
+1 这是一个非常棒的黑客技巧和侧面思考的体现。 - Daniel Earwicker
4个回答

10

在这个有趣的问题的玩乐精神中,我提供了这个可怕的噩梦创造物:

class tls
{
    void push(void *ptr)
    {
        // allocate a string to store the hex ptr 
        // and the hex of its own address
        char *str = new char[100];
        sprintf(str, " |%x|%x", ptr, str);
        strtok(str, "|");
    }

    template <class Ptr>
    Ptr *next()
    {
        // retrieve the next pointer token
        return reinterpret_cast<Ptr *>(strtoul(strtok(0, "|"), 0, 16));
    }

    void *pop()
    {
        // retrieve (and forget) a previously stored pointer
        void *ptr = next<void>();
        delete[] next<char>();
        return ptr;
    }

    // private constructor/destructor
    tls() { push(0); }
    ~tls() { pop(); }

public:
    static tls &singleton()
    {
        static tls i;
        return i;
    }

    void *set(void *ptr)
    {
        void *old = pop();
        push(ptr);
        return old;
    }

    void *get()
    {
        // forget and restore on each access
        void *ptr = pop();
        push(ptr);
        return ptr;
    }
};

利用 C++ 标准中规定的事实,strtok 函数会储存它的第一个参数,这样后续调用可以传递 0 来从同一字符串中检索更多的标记。因此,在支持线程的实现中,strtok 必须使用 TLS。

example *e = new example;

tls::singleton().set(e);

example *e2 = reinterpret_cast<example *>(tls::singleton().get());

只要在程序中没有其他地方意外地使用了 strtok,我们就有另一个可用的TLS插槽。


strtok的定义是基于一系列调用的,因此无法确定一个线程感知的实现是否保留TLS指针或带有互斥锁的全局变量。尽管如此,我感到非常震惊。 - Potatoswatter
1
我很高兴它产生了预期的效果! :) 如果 strtok 只是由每次调用都会锁定的互斥锁保护,那就不会真正有所帮助,因此 TLS 是唯一可能的解决方案。虽然我想它可能会有一些变态的用途,比如作为线程之间半可靠的通信渠道!除此之外,我还可以构建一个流接口,就像 TCP over IP... 现在 这才是 一个挑战! - Daniel Earwicker
3
这是在《Paranormal Activity》中困扰人们的恶魔所困扰的代码。 - Eloff

3

我认为你的想法很有道理。这甚至可以是一种便携式的方法,将数据传递到不接受用户“状态”变量的回调函数中,正如你提到的,即使没有任何显式使用线程。

因此,从你的主题中听起来,你已经回答了问题:是的。


0
void *kid( void *local_v ) try {
    thlocal &local = * static_cast< thlocal * >( local_v );
    throw local;
} catch( thlocal & ) {
    print_thread();

    return NULL;
}

==

void *kid (void *local_v ) { print_thread(local_v); }

我可能在这里漏掉了什么,但它不是线程本地存储,只是不必要的复杂参数传递。每个线程的参数不同仅仅是因为它被传递给pthread_create,而不是因为任何异常处理。

事实证明,我确实错过了GCC在这个例子中产生实际线程本地存储调用的事实。这使得问题变得有趣。我仍然不确定其他编译器是否也存在这种情况,以及与直接调用线程存储有何不同。

我仍然坚持我的一般论点,即可以以更简单和直接的方式访问相同的数据,无论是参数、堆栈遍历还是线程本地存储。


提供示例有些棘手,因为线程本地存储本身并不是必需的,它只是一种方便。 - ima
但是我认为,如果您重写示例以使用更复杂的函数调用图,包括树和循环,您将看到它等同于向每个函数传递(void*)。 - ima
我的错误,两个throw是本地的,而一个throw不太重要。 “所以它不像堆栈” - 它不是“像”,它“就是”一个堆栈。你认为thread_exception_base对象存储在哪里?如果你怀疑,请检查反汇编。你正在使用异常处理程序来访问堆栈,而不是更常规的方法,这并没有提供任何新的功能,只是一个堆栈。 - ima
看起来我在这里错了,很抱歉我固执了。虽然完全意想不到,但是为什么GCC会为异常使用线程局部存储呢?我需要深入研究一下。这引发了另一个问题-如果是这种情况,为什么不直接调用getspecific呢? - ima
@ima:我同意,可以通过遍历堆栈来实现异常而不使用getspecificgetspecific只是更快。同样,getspecific也可以通过堆栈遍历来实现...功能是等效的。但是,我不知道另一种可移植的方法。 (如果您知道,我可能会选择您的答案。)当然,直接调用getspecific会快得多,但是这样我可以编写仍然适用于单线程或非pthread平台的代码。 - Potatoswatter
显示剩余11条评论

0

访问当前函数调用堆栈上的数据始终是线程安全的。这就是为什么您的代码是线程安全的,而不是因为聪明地使用异常。线程本地存储允许我们存储每个线程的数据并在直接调用堆栈之外引用它。


调用堆栈上的数据如果使用不当是非常不安全的。线程本地存储并不比其他任何东西更安全,它只是由线程键入。 - Potatoswatter
这与线程安全无关,只涉及线程本地存储。 - falstro
如果你在栈上声明了一小段数据,并将其作为额外的参数传递给每个函数,那么你就可以有效地拥有线程本地存储。TLS的优势在于你不需要添加额外的参数。这个想法的巧妙之处在于你可以写出 void foo() { throw; } 这样的语句,其中 throw 没有本地上下文来确定它应该抛出什么异常,因此运行时必须在每个调用栈中保留一个 throw 对象,也就是说,每个线程都有一个。因此,这是一种只在线程内共享值而无需使用参数传递的方式,就像TLS一样。 - Daniel Earwicker
我的原始评论中确实包含一个我认为是打字错误的严重错误,但您可以自行判断。无论如何,对于造成的混淆,我表示歉意;我已经纠正了这个错误。我仍然不明白异常如何取代线程本地存储。我想不出任何场景,在当前线程上或关闭当前线程时将堆栈变量传递给函数,会创建每个线程变量实例,这就是线程本地存储的作用。 - Paul Keister
这不是堆栈变量。它是 C++ 运行时库提供的存储槽,用于存储最近捕获的异常,以便 throw; 可以检索并重新抛出它。这里的妙点是将任意数据存储在异常对象中,使用 throw/catch 获取存储在插槽中的异常,然后使用 throw; 在其他位置检索它。因此它是一个“全局”的存储槽... - Daniel Earwicker
但是在支持线程的 C++ 实现中,它必须被更改为一个线程本地存储插槽,否则线程 1 可能会不经意地重新抛出在线程 2 中刚刚捕获的东西,而不是重新抛出线程 1 中刚刚捕获的东西。 - Daniel Earwicker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接