C++线程thread知识点

C++线程thread知识点

线程 thread

1、sleep 和 wait 的区别

对于 sleep() 方法,我们首先要知道该方法是属于 Thread 类中的。而 wait() 方法,则是属于 Object 类中的。

  1. sleep() 方法导致了程序暂停执行指定的时间,让出 CPU 给其他线程,但是它的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。
  2. 在调用 sleep() 方法的过程中,线程不会释放对象锁。
  3. 而当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify() 方法后本线程才进入对象锁定池准备。
  4. sleep() 可以在任何地方使用,而 wait() 只能在同步方法或者同步块中使用。
  5. sleep() 是 Thread 的静态方法,wait() 是 Object 的方法,任何对象实例都能调用。
  6. sleep() 不会释放锁,它也不需要占用锁。wait() 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)。
  7. 它们都可以被 interrupted 方法中断。
  8. Thread.Sleep(0) 的作用,就是触发操作系统立刻重新进行一次 CPU 竞争,竞争的结果也许是当前线程仍然获得 CPU 控制权,也许会换成别的线程获得 CPU 控制权。
  9. wait(1000) 表示将锁释放 1000 毫秒,到时间后如果锁没有被其他线程占用,则再次得到锁,然后 wait() 方法结束,执行后面的代码,如果锁被其他线程占用,则等待其他线程释放锁。注意,设置了超时时间的 wait() 方法一旦过了超时时间,并不需要其他线程执行 notify() 也能自动解除阻塞,但是如果没设置超时时间的 wait() 方法必须等待其他线程执行 notify()。
wait() sleep()
同步 只能在同步上下文中调用 wait() 方法 不需要在同步方法或同步块中调用
调用对象 wait() 作用于对象本身 sleep() 作用于当前线程
释放锁资源
唤醒条件 其他线程调用对象的 notify() 或者 notifyAll() 方法 超时或者用 interrupt() 方法
方法属性 wait() 是实例方法 sleep() 是静态方法

2、线程的分类

1、IO 线程,这类线程的主循环是 IO multiplexing,阻塞地等在 select/poll/epoll_wait 系统调用上。这类线程也处理定时事件。当然它的功能不止 IO,有些简单计算也可以放入其中,比如消息的编码或解码。
2、计算线程,这类线程的主循环是 blocking queue,阻塞地等待在 condition variable 上。这类线程一般位于 thread pool 中。这种线程不涉及 IO,一般要避免任何阻塞操作。
3、第三库所用的线程,比如 logging,又比如 database connection。

3、Linux同时启动线程的数目

对于 32-bit Linux,一个进程的地址空间是 4GiB,其中用户态能访问 3GiB 左右,而一个线程的默认栈(stack)大小是 10MB,一个进程大约最多能同时启动300个线程。如果不改线程的调用栈大小的话,300左右是上限,因为程序的其他部分(数据段、代码段、堆、动态库等等)同样要占用内存(地址空间)。

4、第三方库用自己的线程

第三库不一定能很好地适应并融入这个 event loop framework,有时需要用线程来做一些串并转换。比如检测串口上的数据到达可以用文件描述符的可读事件,因此可以方便地融入 event loop。但是检测串口上的某些控制信号(DCD)只能用轮询(ioctl(fd, TIOCMGET, &flags))或阻塞等待(ioctl(fd, TIOCMIWAIT, TIOCM_CAR)),想要融入 event loop,需要单独起一个线程来查询串口信号翻转,再转换为文件描述符的读写事件(可以通过 pipe)。

5、工作集

指服务程序响应一次请求所访问的内存大小。如果工作集较大,就用多线程,避免 CPU cache 换入换出,影响性能;否则就用单线程多进程。
线程不能减少工作量,即不能减少 CPU 时间。

6、线程原语

11个最基本的 Pthreads 函数:

  • 2个:线程的创建和等待结束(join)
  • 4个:mutex 的创建、销毁、加锁、解锁
  • 5个:条件变量的创建、销毁、等待、通知、广播
    不推荐使用读写锁的原因是它往往造成提高性能的错觉(允许多个线程并发读),实际上在很多情况下,与使用简单的 mutex 相比,它实际上降低了性能。写操作会阻塞读操作,如果要求优化读操作的延迟,用读写锁是不合适的。

7、内存序(内存能见度)

规定一个线程多某个共享变量的修改何时能被其他线程看到。

8、线程库

第一个支持用户态线程的 Unix 操作系统出现在 20 世纪 90 年代早期。线程库的出现给系统函数库也带来了冲击:

  • errno 不再是全局变量,因为每个线程可能会执行不同的系统库函数。
  • 有些纯函数不受影响,如 memset/strcpy/snprintf 等等。
  • 有些影响全局状态或者有副作用的函数可以通过加锁来实现线程安全,如 malloc/free、printf、fread/fseek 等等。
  • 有些返回或使用静态空间的函数不可能做到线程安全,因此要提供另外的版本,如 asctime_r/ctime_r/gmtime_r、stderror_r、strtok_r 等等。
  • 传统的 fork() 并发模型不再适用于多线程程序。

9、线程安全性

不必担心系统调用的安全性,因为系统调用对于用户态程序来说是原子的。

尽管单个函数是线程安全的,但是对某个文件 “先 seek 再 read” 这两步操作中间有可能会被打断,其他线程有可能趁机修改了文件的当前位置,让程序逻辑无法正确执行。可以用 flockfile(File) 和 funlockfile(File) 函数来显示地加锁。并且由于 File* 的锁是可重入的,加锁之后再调用 fread() 不会造成死锁。

编写线程安全程序的一个难点在意线程安全是不可组合的(composable),一个函数 foo() 调用了两个线程安全的函数,而这个 foo() 函数本身很可能不是线程安全的。

凡是非共享的对象都是彼此独立的,如果一个对象从始至终只被一个线程用到,那么它就是安全的。另外一个事实标准是:共享的对象的 read-only 操作是安全的,前提是不能有并发的写操作。

单个成员函数的线程安全并具备可组合性(composable)。假设有 safe_vector class,它的接口与 std::vector 相同,不过每个成员函数都是线程安全的(类似 Java synchronized 方法)。但是用 safe_vector 并不一定能写出线程安全的代码。

1
2
3
4
5
6
safe_vector<int> vec;   // 全局可见

if (!vec.empty()) // 没有加锁保护
{
int x = vec[0]; // 这两步在多线程下是不安全的
}

在 if 语句判断 vec 非空之后,别的线程可能清空其元素,从而造成 vec[0] 失效。

C++ 标准库中的绝大数泛型算法是线程安全的,因为这些都是无状态纯函数。只要输入区间是线程安全的,那么泛型函数就是线程安全的。

线程标识

POSIX threads 库提供了 pthread_self 函数用于返回当前进程的标识符,类型为 pthread_t。pthread_t 不一定是一个数值类型(整数或指针),也可能是一个结构体,Pthreads 提供了 pthread_equal 函数用于对比两个线程标识符是否相等。但有如下问题:
1、无法打印输出 pthread_t,因为不知道其确切类型。也就没法在日志中用它表示当前线程的 id。
2、无法比较 pthread_t 的大小或计算其 hash 值,因此无法用作关联容器的 key。
3、无法定义一个非法的 pthread_t 值,用来表示绝对不可能存在的线程 id,因此 MutexLock class 没有办法有效判断当前线程是否已经持有本锁。
4、pthread_t 值只在进程内有意义,与操作系统的任务调度之间无法建立有效关联。比如 /proc 文件系统中找不到 pthread_t 对应的 task。

Pthreads 只保证同一进程之内,同一时刻的各个线程的 id 不同;不能保证同一进程先后多个线程具有不同的 id,更不要说同一台机器上多个进程之间的 id 唯一性了。在 Linux 上,建议使用 gettid() 系统调用的返回值作为线程 id,理由如下:
1、它的类型是 pid_t,其值通常是一个小整数,便于在日志中输出。
2、它直接表示内核的任务调度 id,因此在 /proc 文件系统中可以轻易找到对应项:/proc/tid 或 /proc/pid/task/tid。
3、在其他系统工具中也容易定位到具体某一个线程,如在 top 中可以按线程列出任务,然后找出 CPU 使用率最高的线程 id,再根据程序日志判断到底哪一个线程在耗用 CPU。
4、任何时刻都是全局唯一的,并且由于 Linux 分配新 pid 采用递增轮回办法,短时间内启动的多个线程也会具有不同的线程 id。
5、0是非法值,因为操作系统第一个进程 init 的 pid 是1。

10、线程创建与销毁

创建:
1、程序库不应该在未提前告知的情况下创建自己的“背景线程”。
2、尽量用相同的方式创建线程。
3、在进入 main() 函数之前不应该启动线程。会影响全局对象的安全构造。
4、程序中线程的创建最好能在初始化阶段全部完成。

销毁:
1、自然死亡。从线程主函数返回,线程正常退出。
2、非正常死亡。从线程主函数抛出异常或线程触发 segfault 信号等非法操作。
3、自杀。在线程中调用 pthread_exit() 来立刻退出线程。
4、他杀。其他线程调用 pthread_cancel() 来强制终止某个线程。

pthread_kill() 是往线程发信号。

线程正常退出的方式只有一种,即自然死亡。因为强行终止线程的话(无论是自杀还是他杀),它都没有机会清理资源,也没有机会释放已经持有的锁,其他线程如果再想对同一个 mutex 加锁,那么就会立刻死锁。

exit() 函数在 C++ 中的作用除了终止进程,还会析构全局对象和已经构造完的函数静态对象。这有潜在的死锁可能。

11、__thread 关键字

__thread 是 GCC 内置的线程局部存储设施(thread local storage)。存取效率可与全局变量相比。

1
2
int g_var;          // 全局变量
__thread int t_var; // __thread 变量

只能用于修饰 POD 类型,不能修饰 class 类型,因为无法自动调用构造函数和析构函数。thread 可以用于修饰全局变量、函数内的静态变量,但是不能修饰函数的局部变量或者 class 的普通成员变量。thread 变量的初始化只能用编译期常量。

1
2
3
__thread string t_obj1("hello world");  // 错误,不能调用对象的构造函数
__thread string *t_obj2 = new string; // 错误,初始化必须用编译期常量
__thread string *t_obj3 = NULL; // 正确,但是需要手工初始化并销毁对象

__thread 变量是每个线程有一份独立实体,各个线程的变量值互不干扰。它还可以修饰那些“值可能会变,带有全局性,但是又不值得用全局锁保护”的变量。

12、多线程与 IO

操作文件描述符的系统调用本身是线程安全的,不用担心多个线程同时操作文件描述符会造成进程奔溃或内核奔溃。

  • 多个线程操作同一个 socket 需要考虑的情况:
    1、如果一个线程正在阻塞地 read() 某个 socket,而另一个线程 close() 了此 socket。
    2、如果一个线程正在阻塞地 accept() 某个 listening socket,而另一个线程 close() 了此 socket。
    3、一个线程正准备 read() 某个 socket,而另一个线程 close() 了此 socket;第三个线程又恰好 open() 了另一个文件描述符,其 fd 正好与前面的 socket 相同。
  • 读写情况:
    1、如果两个线程同时 read() 同一个 TCP socket,两个线程几乎同时各自收到一部分数据,如何把数据拼成完整的消息?如何知道哪部分数据先到达?
    2、如果两个线程同时 write() 同一个 TCP socket,每个线程都只发出去半条消息,那接收方收到数据如何处理?
    3、如果给每个 TCP socket 配一把锁,让同时只能有一个线程读或写此 socket,但是这样还不如直接始终让同一个线程来操作此 socket。
    4、对于非阻塞 IO,收发消息的完整性与原子性几乎不可能用锁来保证,因为这样会阻塞其他 IO 线程。

每个文件描述符只由一个线程操作,从而轻松解决消息收发的顺序性问题,也避免了关闭文件描述符的各种 race condition。一个线程可以操作多个文件描述符,但一个线程不能操作别的线程拥有的文件描述符。

这条规则有连个例外:对于磁盘文件,在必要的时候多个线程可以同时调用 pread()/pwrite() 来读写同一个文件;对于 UDP,由于协议本身保证消息的原子性,在适当的条件下(比如消息之间彼此独立)可以多个线程同时读写同一个 UDP 文件描述符。

评论

:D 一言句子获取中...

加载中,最新评论有1分钟缓存...