Golang unsafe.Pointer使用原則以及 uintptr 隱藏的坑

2021-02-15 wegoer

本文目錄:

new的對象,內存在哪裡開闢

使用go標準編譯器編譯的代碼,每個協程都會有自己的協程棧,一個協程棧是一個預申請的內存塊。每個協程的初始棧大小比較小(在64位系統上2KB)。每個棧的大小在協程運行的時候將按照需要增長和收縮( stack grow )。

當我們創建對象申請內存塊的時候,可以從協程棧上申請,也可以從堆上申請。

從協程棧上申請的對象,只能在此協程內部被使用(引用),比如指針指向的對象不能逃逸到協程外,其它協程是無法訪問到這些內存塊的。一個協程不需要使用任何數據同步技術而使用開闢在它的棧上的內存塊上的值。

也可以從堆上申請對象, 開闢在堆上的內存塊可以被多個協程並發地訪問。在需要的時候需要做並發安全控制。

編譯器在編譯代碼時候會做逃逸分析,關於逃逸分析細節可以參考:golang 逃逸分析與棧、堆分配分析[^1]

逃逸分析在編譯階段可以確定一個對象是分配在堆上還是協程棧上。如果編譯器覺察到一個內存塊在運行時將會被多個協程訪問,或者不能輕鬆地斷定此內存塊是否只會被一個協程訪問,則此內存塊將會被開闢在堆上。也就是說,編譯器將採取保守但安全的策略,使得某些可以安全地被開闢在棧上的內存塊也有可能會被開闢在堆上。

go支持協程棧是為了提升性能。

如上所述,目前官方Go編譯器中的逃逸分析器並不十分完美,因此某些可以安全地開闢在棧上的值也可能會逃逸到了堆上。

不過我們可以認為每個包級變量(常稱全局變量)都被開闢在了堆上,並且它被一個開闢在一個全局內存區上的隱式指針所引用著。一個開闢在堆上的內存塊可能同時被開闢在若干不同棧上的值部所引用著。

一些事實:

如果一個結構體值的一個欄位逃逸到了堆上,則此整個結構體值也逃逸到了堆上。如果一個數組的某個元素逃逸到了堆上,則此整個數組也逃逸到了堆上。如果一個切片的某個元素逃逸到了堆上,則此切片中的所有元素都將逃逸到堆上,但此切片值的直接部分(SliceHeader)可能開闢在棧上。如果一個值部v被一個逃逸到了堆上的值部所引用,則此值部v也將逃逸到堆上。

使用內置new函數開闢的內存可能開闢在堆上,也可能開闢在棧上,也就是不是所有的指針指向的對象都保存在堆上。這是與C++不同的一點。

當一個協程的棧的大小改變(grow)時,一個新的內存段將申請給此棧使用。原先已經開闢在老的內存段上的內存塊將很有可能被轉移到新的內存段上,或者說這些內存塊的地址將改變。相應地,引用著這些開闢在此棧上的內存塊的指針(它們同樣開闢在此棧上)中存儲的地址也將得到刷新。這裡很重要,這也是 uintptr 變量不要輕易使用的原因。

unsafe.Pointer 和 uintptr 是什麼

關於這一塊可以參考:golang unsafe實踐與原理[^2]

這裡需要再次強調的是:uintptr 就是一個16進位的整數,這個數字表示對象的地址,但是uintptr沒有指針的語義。所以有一些情況:一,如果一個對象只有一個 uintptr 表示的地址表示"引用"關係,那麼這個對象會在GC時被無情的回收掉,那麼uintptr表示一個野地址。二,如果uintptr表示的地址指向的對象發生了copy移動(比如協程棧增長,slice的擴容等),那麼uintptr也表示一個野地址。但是unsafe.Pointer 有指針語義,可以保護它所指向的對象在「有用」的時候不會被垃圾回收,並且在發生移動時候更新地址值。

正確地使用非類型安全指針

這部分主要是參考unsafe.Pointer的官方文檔[^3]和:go101的非類型安全指針一文[^4]

一些事實1. 非類型安全指針值(unsafe.Pointer)是指針但uintptr值是整數

每一個非零安全或者不安全指針值均引用著另一個值。但是一個uintptr值並不引用任何值,它被看作是一個整數,儘管常常它存儲的是一個地址的數字表示。

Go的GC會檢查對象引用關係並回收不再被程序中的任何仍在使用中的值所引用的對象。指針在這一過程中扮演著重要的角色。值與值之間和內存塊與值之間的引用關係是通過指針來表徵的。

既然一個uintptr值是一個整數,那麼它可以參與算術運算。

2. 不再被使用的內存塊的回收時間點是不確定的

也就是GC的開始時間是不確定的。

下面有個例子:

import "unsafe"

// 假設此函數不會被內聯(inline)。
func createInt() *int {
 return new(int)
}

func foo() {
 p0, y, z := createInt(), createInt(), createInt()
 var p1 = unsafe.Pointer(y) // 和y一樣引用著同一個值
 var p2 = uintptr(unsafe.Pointer(z))

 // 此時,即使z指針值所引用的int值的地址仍舊存儲
 // 在p2值中,但是此int值已經不再被使用了,所以垃圾
 // 回收器認為可以回收它所佔據的內存塊了。另一方面,
 // p0和p1各自所引用的int值仍舊將在下面被使用。

 // uintptr值可以參與算術運算。
 p2 += 2; p2--; p2--

 *p0 = 1                         // okay
 *(*int)(p1) = 2                 // okay
 *(*int)(unsafe.Pointer(p2)) = 3 // 危險操作!
}

值p2是一個 uintptr, 不具有指針含義而是一個整數,所以不能保證z指針值所引用的int值所佔的內存塊一定還沒有被回收。換句話說,當*(*T)(unsafe.Pointer(p2))) = 3被執行的時候,此內存塊有可能已經被回收了。所以,接引p2中存儲的地址可能是接引野指針。

3. 一個值的地址在程序運行中可能改變

參考 unsafe.Pointer 和 uintptr 是什麼

這裡我們只需要知道當一個協程的棧的大小改變時,開闢在此棧上的內存塊需要移動,從而相應的值的地址將改變。

4. 我們可以將一個值的指針傳遞給runtime.KeepAlive函數調用來確保此值在此調用之前仍然處於被使用中

為了確保一個值部和它所引用著的值部仍然被認為在使用中,我們應該將引用著此值的另一個值傳給一個runtime.KeepAlive函數調用。在實踐中,我們常常將此值的指針傳遞給一個runtime.KeepAlive函數調用。

還是上面 事實二 的例子:

func foo() {
 p0, y, z := createInt(), createInt(), createInt()
 var p1 = unsafe.Pointer(y)
 var p2 = uintptr(unsafe.Pointer(z))

 p2 += 2; p2--; p2--

 *p0 = 1
 *(*int)(p1) = 2
 *(*int)(unsafe.Pointer(p2))) = 3 // 轉危為安!

 runtime.KeepAlive(z) // 確保z所引用的值仍在使用中
}

這裡通過最後添加一個runtime.KeepAlive(z)調用,表明在調用runtime.KeepAlive(z)之前,z指針指向的地址都不會被GC回收。那麼*(*int)(unsafe.Pointer(p2))) = 3可以被安全地執行了。

5. 一個值的可被使用範圍可能並沒有代碼中看上去的大

比如下面這個例子,值t仍舊在使用中並不能保證被值t.y所引用的值仍在被使用。

 uintptr(unsafe.Pointer(&t.y[0]))

 ... // 使用t.x和t.y

 // 一個聰明的編譯器能夠覺察到值t.y將不會再被用到,
 // 所以認為t.y值所佔的內存塊可以被回收了。

 *(*byte)(unsafe.Pointer(p)) = 1 // 危險操作!

 println(t.x) // ok。繼續使用值t,但只使用t.x欄位。
}

6. *unsafe.Pointer是一個類型安全指針類型

是的,類型*unsafe.Pointer是一個類型安全指針類型。它的基類型為unsafe.Pointer。既然它是一個類型安全指針類型,根據上面列出的類型轉換規則,它的值可以轉換為類型unsafe.Pointer,反之亦然。

正確使用非類型安全的指針的一些模式

unsafe標準庫包的文檔中列出了六種非類型安全指針的使用模式[^5]。

模式一:將類型T1的一個值轉換為非類型安全指針值,然後將此非類型安全指針值轉換為類型T2。

利用前面列出的非類型安全指針相關的轉換規則,我們可以將一個T1值轉換為類型T2,其中T1和T2為兩個任意類型。然而,我們只有在T1的尺寸不大於T2並且此轉換具有實際意義的時候才應該實施這樣的轉換。

模式二:將一個非類型安全指針值轉換為一個uintptr值,然後使用此uintptr值。

此模式不是很有用。一般我們將最終的轉換結果uintptr值輸出到日誌中用來調試,但是有很多其它安全的途徑也可以實現此目的。

這個模式也不是很推薦,因為 uintptr 指向的地址是不穩定的。

模式三:將一個非類型安全指針轉換為一個uintptr值,然後此uintptr值參與各種算術運算,再將算術運算的結果uintptr值轉回非類型安全指針。

例子如下:

package main

import "fmt"
import "unsafe"

type T struct {
 x bool
 y [3]int16
}

const N = unsafe.Offsetof(T{}.y)
const M = unsafe.Sizeof([3]int16{}[0])

func main() {
 t := T{y: [3]int16{123, 456, 789}}
 p := unsafe.Pointer(&t)
 // "uintptr(p) + N + M + M"為t.y[2]的內存地址。
 ty2 := (*int16)(unsafe.Pointer(uintptr(p)+N+M+M))
 fmt.Println(*ty2) // 789
}
1234567891011121314151617181920

在上面這個例子中,地址轉換代碼 unsafe.Pointer(uintptr(p) + N + M + M) 必須要用一行運算。

假設拆成兩行:

addr := uintptr(p) + N + M + M
// 從這裡到下一行代碼執行之前,t值將不再被任何值
// 引用,所以垃圾回收器認為它可以被回收了。一旦
// 它真得被回收了,下面繼續使用t.y[2]值的曾經
// 的地址是非法和危險的!另一個危險的原因是
// t的地址在執行下一行之前可能改變(見事實三)。
// 另一個潛在的危險是:如果在此期間發生了一些
// 導致協程堆棧大小改變的情況,則記錄在addr中
// 的地址將失效。當然,此危險對於這個特定的例子
// 並不存在。
ty2 := (*int16)(unsafe.Pointer(addr))
fmt.Println(*ty2)

這樣的bug是非常微妙和很難被覺察到的,並且爆發出來的機率是相當得低。一旦這樣的bug爆發出來,將很讓人摸不到頭腦。這是為什麼使用非類型安全指針是危險的原因之一。

如果我們確實希望將上面提到的轉換拆成兩行,我們應該在拆分後的兩行後添加一條runtime.KeepAlive函數調用並將(直接或間接)引用著t.y[2]值的一個值傳遞給此調用做為實參。比如:

func main() {
 t := T{y: [3]int16{123, 456, 789}}
 p := unsafe.Pointer(t)
 addr := uintptr(p) + N + M + M
 ty2 := (*int16)(unsafe.Pointer(addr))
 // 下面這條調用將確保整個t值的內存
 // 在此時刻不會被回收。
 runtime.KeepAlive(p)
 fmt.Println(*ty2)
}

但是並不推薦在此使用模式中使用此runtime.KeepAlive技巧。具體原因見上面的注釋中提到的潛在的危險。因為存在著這樣一種可能:當Go運行時為變量ty2開闢內存的時候,當前協程的棧的大小需要進行增大調整。調整之後t的地址將改變,但是存儲在變量addr中的地址值卻未得到更新(因為只有開闢在棧上的指針類型的值才會被更新,而變量addr的類型為整數類型uintptr)。這直接導致存儲在變量ty2的地址值時無效的(野指針)。但是,實事求是地講,如果上例中的代碼使用官方標準編譯器編譯,則此潛在的危險並不存在。原因是在官方標準編譯器的實現中,一個runtime.KeepAlive調用將使它的實參和被此實參引用的值開闢到堆上,並且開闢在堆上的內存塊從不會被移動。

模式四:將非類型安全指針值轉換為uintptr值並傳遞給syscall.Syscall函數調用。

過對上一個使用模式的解釋,我們知道像下面這樣含有uintptr類型的參數的函數定義是危險的。

// 假設此函數不會被內聯。
func DoSomething(addr uintptr) {
 // 對處於傳遞進來的地址處的值進行讀寫...
}

上面這個函數是危險的原因在於此函數本身不能保證傳遞進來的地址處的內存塊一定沒有被回收。如果此內存塊已經被回收了或者被重新分配給了其它值,那麼此函數內部的操作將是非法和危險的。

然而,syscall標準庫包中的Syscall函數的原型為:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

那麼此函數是如何保證處於傳遞給它的地址參數值a1、a2和a3處的內存塊在此函數執行過程中一定沒有被回收和被移動呢?此函數無法做出這樣的保證。事實上,是編譯器做出了這樣的保證。這是syscall.Syscall這樣的函數的特權。其它自定義函數無法享受到這樣的待遇。

我們可以認為編譯器針對每個syscall.Syscall函數調用中的每個被轉換為uintptr類型的非類型安全指針實參添加了一些指令,從而保證此非類型安全指針所引用著的內存塊在此調用返回之前不會被垃圾回收和移動。

模式五:將reflect.Value.Pointer或者reflect.Value.UnsafeAddr方法的uintptr返回值轉換為非類型安全指針。

reflect標準庫包中的Value類型的Pointer和UnsafeAddr方法都返回一個uintptr值,而不是一個unsafe.Pointer值。這樣設計的目的是避免用戶不引用unsafe標準庫包就可以將這兩個方法的返回值(如果是unsafe.Pointer類型)轉換為任何類型安全指針類型。

這樣的設計需要我們將這兩個方法的調用的uintptr結果立即轉換為非類型安全指針。否則,將出現一個短暫的可能導致處於返回的地址處的內存塊被回收掉的時間窗。此時間窗是如此短暫以至於此內存塊被回收掉的機率非常之低,因而這樣的編程錯誤造成的bug的重現機率亦十分得低。

比如,下面這個調用是安全的:

p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

而下面這個調用是危險的:

// 返回的u類型是uintptr
u := reflect.ValueOf(new(int)).Pointer()
// 在這個時刻,處於存儲在u中的地址處的內存塊
// 可能會被回收掉。
p := (*int)(unsafe.Pointer(u))

模式六:將一個reflect.SliceHeader或者reflect.StringHeader值的Data欄位轉換為非類型安全指針,以及其逆轉換。

和上一小節中提到的同樣的原因,reflect標準庫包中的SliceHeader和StringHeader類型的Data欄位的類型被指定為uintptr,而不是unsafe.Pointer。

參考官方文檔的一句話:

In general, reflect.SliceHeader and reflect.StringHeader should be used only as *reflect.SliceHeader and *reflect.StringHeader pointing at actual slices or strings, never as plain structs. A program should not declare or allocate variables of these struct types.

對於reflect.SliceHeader 和 reflect.StringHeader,只期待使用*reflect.SliceHeader 和 *reflect.StringHeader 指針,而不期待訪問裡面的成員變量,因為裡面的Data屬性是一個 uintptr。

一般說來,我們只應該從一個已經存在的字符串值得到一個*reflect.StringHeader指針, 或者從一個已經存在的切片值得到一個*reflect.SliceHeader指針, 而不應該從一個StringHeader值生成一個字符串,或者從一個SliceHeader值生成一個切片。比如,下面的代碼是不安全的:

var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// 在此時刻,上一行代碼中剛開闢的數組內存塊已經不再被任何值
// 所引用,所以它可以被回收了。
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr)) // 危險!

下面是一個展示了如何通過使用非類型安全途徑將一個字符串轉換為字節切片的例子。和使用類型安全途徑進行轉換不同,使用非類型安全途徑避免了複製一份底層字節序列。

package main

import (
 "fmt"
 "unsafe"
 "reflect"
 "runtime"
 "strings"
)

func String2ByteSlice(str string) (bs []byte) {
 strHdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
 sliceHdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
 sliceHdr.Data = strHdr.Data
 sliceHdr.Len = strHdr.Len
 sliceHdr.Cap = strHdr.Len
 // 下面的KeepAlive是必要的。
 runtime.KeepAlive(&str)
 return
}

func main() {
 str := strings.Join([]string{"Go", "land"}, "")
 s := String2ByteSlice(str)
 fmt.Printf("%s\n", s) // Goland
 s[5] = 'g'
 fmt.Println(str) // Golang
}

reflect標準庫包中SliceHeader和StringHeader類型的文檔提到這兩個結構體類型的定義不保證在以後的版本中不發生改變。好在目前的兩個主流Go編譯器(標準編譯器和gccgo編譯器)都認可當前版本中的定義。這也可以看作是使用非類型安全指針的另一個潛在風險。

我們可以使用類似的實現來將一個字節切片轉換為字符串。然而,當前(Go 1.13),參考 strings.Builder.String()方法,有一個更簡單和更有效的方法來實現這一轉換:

// String returns the accumulated string.
func (b *Builder) String() string {
 return *(*string)(unsafe.Pointer(&b.buf))
}

此實現利用了上述模式一,從SliceHeader的定義和StringHeader的定義,可以看到SliceHeader的size是大於StringHeader的,並且StringHeader和SliceHeader屬性分布是相似的,都是data在第一個屬性(這個不能更改),基於模式一,可以把slice byte轉換成string, 而不發生內存拷貝。但是注意:這種方法是不可逆的,也就是這種病方法不能用於將string 零拷貝 成 []byte。

事實上,為了避免因為忘記調用runtime.KeepAlive函數而造成的危險,在日常編程中更推薦使用我們自定義Data欄位的類型為unsafe.PointerSliceHeader和StringHeader結構體。比如:

type SliceHeader struct {
 Data unsafe.Pointer
 Len  int
 Cap  int
}

type StringHeader struct {
 Data unsafe.Pointer
 Len  int
}

func String2ByteSlice(str string) (bs []byte) {
 strHdr := (*StringHeader)(unsafe.Pointer(&str))
 sliceHdr := (*SliceHeader)(unsafe.Pointer(&bs))
 sliceHdr.Data = strHdr.Data
 sliceHdr.Len = strHdr.Len
 sliceHdr.Cap = strHdr.Len
 
 // 此KeepAlive調用變得不再必需。
 //runtime.KeepAlive(&str)
 return
}

reflect.SliceHeader為啥不要使用於獲取底層數組指針

我們看看 reflect包裡面的定義:

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}

可以看到Data域是一個uintptr, 表示一個整數語義,有幾種case:

如果slice的元素都分配在協程棧上,如果協程棧發生了擴容,slice就是移動,那麼Data就會失效,變成野地址。如果slice發生了擴容(不管分配到堆還是棧),都會導致Data就會失效,變成野地址。如果slice分配在堆上,而且沒有對象引用slice對象,那麼GC會回收,Data域也會失效。

所以真正來說,這個 reflect.SliceHeader 只能用於可讀。

真正安全的string零拷貝

參考上面的模式六,推薦使用自定義的 SliceHeader 和 StringHeader,如下:

// SliceHeader is a safe version of SliceHeader used within this project.
type SliceHeader struct {
 Data unsafe.Pointer
 Len  int
 Cap  int
}

// StringHeader is a safe version of StringHeader used within this project.
type StringHeader struct {
 Data unsafe.Pointer
 Len  int
}

具體轉換過程就不說了,參考文章:golang unsafe實踐與原理[^2]

總結

使用uintptr始終要注意兩點:

協程棧上對象移動導致內存地址不可用(目前(go 1.13)GC算法堆上對象不會移動)

keepAlive的調用保證指針地址所指向對象在調用keepAlive之前部分對象不被回收,並且對象分配在堆上。

參考文獻

[1] golang 逃逸分析與棧、堆分配分析: https://louyuting.blog.csdn.net/article/details/102846449

[2] golang unsafe實踐與原理: https://louyuting.blog.csdn.net/article/details/100178972)

[3] unsafe包官方文檔: https://golang.google.cn/pkg/unsafe/#Pointer

[4] go101 非類型安全指針: https://gfw.go101.org/article/unsafe.html

[5] 六種非類型安全指針的使用模式: https://golang.google.cn/pkg/unsafe/#Pointer

[6] go101 內存塊: https://gfw.go101.org/article/memory-block.html#where-to-allocate

[7] Golang升級到1.7後,之前正確的函數出現錯誤,分析原因及解決辦法: https://zhuanlan.zhihu.com/p/22782641?utm_campaign=studygolang.com&utm_medium=studygolang.com&utm_source=studygolang.com


推薦閱讀:

相關焦點

  • 6個簡例帶你玩轉Golang指針
    The of a pointer is nil. That means any uninitialized pointer will have the value nil.變量的地址可以使用使用&運算符獲得:var x = 100var p *int = &x上面我們通過對x做&運算符來獲取其地址,然後將該地址分配給指針p。
  • 前端JS開發人轉Golang初步
    本文蟲蟲就來給JS開發人員介紹Golang語言,以及JS人員如何開始寫Golang。本文純屬技術探討,不討論語言紛爭,反正各種地方已經爭的已經太多了,也沒個統一的結果。一些相似點與JS一樣,Golang使用垃圾收集器。我們也不需進行任何內存分配/處置。函數式開發是Golang重要組成部分。變量和函數具有作用範圍。Golang函數中也可使用閉包。
  • 為什麼golang語言會變得越來越流行
    作為一個開發者,如果你細心觀察,就會發現越來越多的公司開始使用go語言進行業務的開發。從知乎到b站,很多公司都把業務全面轉向了go語言。那麼為什麼這麼多公司選擇了go語言,為什麼這麼多開發者選擇了go語言,golang變得越來越流行的原因到底是什麼?
  • Golang多級內存池設計與實現
    我們生產環境使用的是阿里雲,打完補丁後,幾臺IO密集型的機器性能下降明顯,從流量和cpu load估計,性能影響在50%左右,不是說好的最多下降30%麼在跑的業務是go寫的,使用go pprof對程序profiling了一下,無意中發現,目前的系統gc和malloc偏高。其中ioutil.ReadAll佔用了可觀的CPU時間。
  • Golang XML解析器漏洞可引發SAML 認證繞過
    XML 解析器不能保證完整性下面列出的Golang XML 語言解析器漏洞導致在編碼和解碼XML 輸入時並不會返回可靠的結果,也就是說XML markup(標記)在使用解析器進行編碼器時會返回不連續的
  • 抖音又坑海底撈,最新爆款隱藏吃法,比「雞蛋蝦滑油麵筋」還坑!
    海底撈本身就很受吃貨們歡迎,現在抖音又讓它的名氣更上一層樓,在繼「雞蛋蝦滑油麵筋」之後,抖音又新出了一種海底撈的隱藏吃法。可是,很多網友看完之後卻覺得,這比「雞蛋蝦滑油麵筋」還坑!到底是什麼情況呢?接下來我們好一起看看吧!
  • 論旅途中遇到過的那些坑與被坑,以及如何避免被坑
    總有人說旅行中這裡被坑,那裡人越來越不淳樸了。我的旅行極少跟團,以自助遊為主,根據我個人這些年的經歷,談一下旅途中所謂的坑來自何處。如果你參加的是正規的品牌旅行社,行程中的購物,雖然很多時候並不是價廉,但買到的東西至少是物美,如果說是坑,
  • 廚房周邊收納的使用便利性和設計原則
    將常用的這些家電集體放在一起,並提供多個電源設備,選用櫃體臺面組合,可以順手使用時很容易,在烹飪過程中作為副臺面使用也很方便。廚房周邊收納櫃的設計原則通過移門和抽屜的開啟和應用,可以使各種尺寸的物品更易於使用。
  • 刷爆微信的隱藏優惠券返利平臺到底是什麼?會有坑嗎?
    而號稱大家購物「買貴了」的這類商家,就是在做隱藏優惠券的,不僅於此,有些購物之後還會給一波返利,讓部分想剁手又嫌棄商品價格過高的消費者感到愛不釋手。那麼,所謂的隱藏優惠券返利,到底說的是什麼?隱藏優惠券返利到底是什麼?
  • 東方時評丨 直播營銷隱藏誤導風險 謹防「入坑」被騙
    隨著直播形式近年來風頭正勁以及人們理財意識的提高,直播金融的上下遊產業鏈對準了很多熱切希望學習理財知識的普通人,然而,其中一些人卻「入坑」被騙。記者調查了解到,金融監管部門提示,人們要注意防範直播營銷中可能隱藏的銷售誤導等風險,樹立科學理性的金融投資、消費觀念。曾經進入一些金融直播間學習的市民感到其中問題不少。
  • 重疾險中小小的輕症,竟然隱藏著這麼多的坑!
    重疾險中看似更容易理賠的輕症,卻隱藏著許多的坑。今天近憂君就來細數輕症中的那些坑。 一、輕症缺少高發疾病 重疾病種中,有25種疾病是保險行業協會統一規定的,幾乎所有重疾險都有包含。
  • 消逝的光芒萌新入坑太難?三分鐘教你拿強力隱藏神器
    各位小夥伴們大家好呀,《消逝的光芒》作為一款十分優秀的開放世界喪屍跑酷遊戲,自發售以來一直熱度不減,萬聖節的一波打折更是讓很多萌新小夥伴入了坑,但是前期有限的體力以及低攻擊力的武器著實勸退了不少玩家,那麼今天我們就一起來看看那些前期就可以輕鬆拿到並且威力強大的隱藏武器吧。
  • 《魔女之泉4》米拉克神殿隱藏支線在哪裡 隱藏支線攻略
    魔女之泉4米拉克神殿隱藏支線在哪裡,很多玩家在入坑了魔女之泉4之後覺得只完成主線心裡非常不舒服,覺得支線也該坐一下來完善一下遊戲的完整性,今天小編就給大家帶來隱藏支線米拉克神殿的有關攻略。,很多玩家在入坑了魔女之泉4之後覺得只完成主線心裡非常不舒服,覺得支線也該坐一下來完善一下遊戲的完整性,今天小編就給大家帶來隱藏支線米拉克神殿的有關攻略。